Preventing Spam with AI
Anyone can submit a feedback. Even robots, spammers, and more. We could add some "captcha" to the form to somehow be protected from robots, or we can use some third-party APIs.
I have decided to use a Large Language Model to decide whether a comment is spam, to demonstrate how to use AI in a Symfony application and how to make such expensive calls "out of band".
Getting an AI API Key
Symfony AI supports many model providers: OpenAI, Anthropic, Google Gemini, Mistral, and even local models via Ollama. This chapter uses OpenAI: sign-up on platform.openai.com and create an API key. If you prefer another provider, the code stays the same; only the configuration changes.
Depending on the Symfony AI Bundle
Instead of calling the model's HTTP API ourselves, we will use the Symfony AI Bundle. It provides a platform abstraction for the model providers (each provider comes as its own bridge package) and an agent that wraps a model to make calls; and it benefits from all the Symfony debugging tools like the integration with the Symfony Profiler:
1
$ symfony composer req symfony/ai-bundle symfony/ai-agent symfony/ai-open-ai-platform
Note
Symfony AI is a young set of components and still experimental: its APIs may evolve faster than the rest of Symfony.
The OpenAI bridge recipe has already configured the platform for us; it references an OPENAI_API_KEY environment variable (and added an empty default for it in .env):
1 2 3 4
ai:
platform:
openai:
api_key: '%env(OPENAI_API_KEY)%'
Configure a default agent on top of it:
1 2 3 4 5
ai:
agent:
default:
platform: 'ai.platform.openai'
model: 'gpt-5-mini'
Using Environment Variables
We certainly don't want to hard-code the key's value in the configuration; that's why it is read from the OPENAI_API_KEY environment variable.
It is then up to each developer to set a "real" environment variable or to store the value in a .env.local file:
1
OPENAI_API_KEY=sk-...
For production, a "real" environment variable should be defined.
That works well, but managing many environment variables might become cumbersome. In such a case, Symfony has a "better" alternative when it comes to storing secrets.
Storing Secrets
Instead of using many environment variables, Symfony can manage a vault where you can store many secrets. One key feature is the ability to commit the vault to the repository (but without the key to open it). Another great feature is that it can manage one vault per environment.
Secrets are environment variables in disguise.
Add the OpenAI API key in the vault:
1
$ symfony console secrets:set OPENAI_API_KEY
1 2 3 4
Please type the secret value:
>
[OK] Secret "OPENAI_API_KEY" encrypted in "config/secrets/dev/"; you can commit it.
Because this is the first time we have run this command, it generated two keys into the config/secret/dev/ directory. It then stored the OPENAI_API_KEY secret in that same directory.
For development secrets, you can decide to commit the vault and the keys that have been generated in the config/secret/dev/ directory.
Secrets can also be overridden by setting an environment variable of the same name.
To read a secret back from the vault, use secrets:reveal:
1
$ symfony console secrets:reveal OPENAI_API_KEY
Designing a Spam Checker Class
Create a new class under src/ named SpamChecker to wrap the logic of asking the model whether a comment is spam:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
namespace App;
use App\Entity\Comment;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Exception\ExceptionInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
class SpamChecker
{
public function __construct(
private AgentInterface $agent,
) {
}
/**
* @return int Spam score: 0: not spam, 1: maybe spam, 2: blatant spam
*/
public function getSpamScore(Comment $comment, array $context): int
{
$messages = new MessageBag(
Message::forSystem(<<<PROMPT
You moderate comments submitted to a conference guestbook.
Classify the comment as "ham", "maybe spam", or "blatant spam".
Only answer with the classification.
PROMPT),
Message::ofUser(sprintf(<<<COMMENT
IP: %s
User agent: %s
Author: %s (%s)
Comment: %s
COMMENT,
$context['user_ip'] ?? '',
$context['user_agent'] ?? '',
$comment->getAuthor(),
$comment->getEmail(),
$comment->getText(),
)),
);
try {
$answer = strtolower($this->agent->call($messages)->getContent());
} catch (ExceptionInterface) {
// when the model cannot answer, let a human moderate the comment
return 1;
}
return match (true) {
str_contains($answer, 'blatant spam') => 2,
str_contains($answer, 'maybe spam') => 1,
default => 0,
};
}
}
The system prompt tells the model its role and constrains its answers; the user message contains the comment and its submission context (IP address, user agent).
The getSpamScore() method returns 3 values depending on the model's answer:
2: if the comment is a "blatant spam";1: if the comment might be spam, or when the model cannot be reached;0: if the comment is not spam (ham).
A model's output is free text, even when the prompt constrains it: parse it liberally (lowercase it, use str_contains()). And when the model cannot answer at all, fall back to human moderation instead of failing: AI should help the admin, never block the guestbook.
Tip
Try submitting a comment that looks blatantly spammy, like "Buy cheap watches at http://example.com/!!!", to see the model at work.
Checking Comments for Spam
One simple way to check for spam when a new comment is submitted is to call the spam checker before storing the data into the database:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
--- i/src/Controller/ConferenceController.php
+++ w/src/Controller/ConferenceController.php
@@ -7,7 +7,8 @@ use App\Entity\Conference;
use App\Form\CommentType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
+use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -34,8 +35,9 @@ final class ConferenceController extends AbstractController
Request $request,
#[MapEntity(mapping: ['slug' => 'slug'])]
Conference $conference,
CommentRepository $commentRepository,
+ SpamChecker $spamChecker,
#[Autowire('%photo_dir%')] string $photoDir,
#[MapQueryParameter] int $offset = 0,
): Response {
$comment = new Comment();
@@ -48,6 +50,17 @@ final class ConferenceController extends AbstractController
}
$this->entityManager->persist($comment);
+
+ $context = [
+ 'user_ip' => $request->getClientIp(),
+ 'user_agent' => $request->headers->get('user-agent'),
+ 'referrer' => $request->headers->get('referer'),
+ 'permalink' => $request->getUri(),
+ ];
+ if (2 === $spamChecker->getSpamScore($comment, $context)) {
+ throw new \RuntimeException('Blatant spam, go away!');
+ }
+
$this->entityManager->flush();
return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
Check that it works fine.
Rate Limiting Comment Submissions
Detecting spam protects the website against sophisticated spammers. A complementary and much cheaper protection is to limit how fast the same client can submit comments: nobody legitimately posts dozens of comments per hour on a guestbook.
Add the Symfony Rate Limiter component:
1
$ symfony composer req rate-limiter
Configure a limiter that accepts at most 5 comments per hour from the same client:
1 2 3 4 5 6 7 8 9 10 11 12
framework:
rate_limiter:
comment_submission:
policy: 'fixed_window'
limit: 5
interval: '1 hour'
when@test:
framework:
rate_limiter:
comment_submission:
limit: 1000
Automated tests legitimately submit many comments in a short period of time, so the limit is raised for the test environment.
Enforce the limiter on comment submissions with the #[RateLimit] attribute; by default, it identifies clients by their IP address:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
--- i/src/Controller/ConferenceController.php
+++ w/src/Controller/ConferenceController.php
@@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
+use Symfony\Component\HttpKernel\Attribute\RateLimit;
use Symfony\Component\Routing\Attribute\Route;
final class ConferenceController extends AbstractController
@@ -31,6 +32,7 @@ final class ConferenceController extends AbstractController
]);
}
+ #[RateLimit('comment_submission', methods: ['POST'])]
#[Route('/conference/{slug}', name: 'conference')]
public function show(
Request $request,
Note the methods argument: browsing a conference page is a GET request and must not be limited; only comment submissions (POST requests) are.
When the limit is reached, Symfony automatically returns a 429 Too Many Requests response with a Retry-After HTTP header telling the client when it can retry.
The same component also protects the admin login form against brute-force attacks; enabling login throttling on the firewall takes one line:
1 2 3 4 5 6 7 8 9 10
--- i/config/packages/security.yaml
+++ w/config/packages/security.yaml
@@ -19,6 +19,7 @@ security:
main:
lazy: true
provider: app_user_provider
+ login_throttling: ~
form_login:
login_path: app_login
check_path: app_login
By default, Symfony blocks an IP after 5 failed login attempts on the same username within a minute (a successful login resets the counter). Use the max_attempts and interval options to tune the policy.
Managing Secrets in Production
For production, Upsun supports setting sensitive environment variables:
1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:OPENAI_API_KEY --value=sk-abcdef
But as discussed above, using Symfony secrets might be better. Not in terms of security, but in terms of secret management for the project's team. All secrets are stored in the repository and the only environment variable you need to manage for production is the decryption key. That makes it possible for anyone in the team to add production secrets even if they don't have access to production servers. The setup is a bit more involved though.
First, generate a pair of keys for production use:
1
$ symfony console secrets:generate-keys --env=prod
On Linux and similiar OSes, use
APP_RUNTIME_ENV=prodinstead of--env=prodas this avoids compiling the application for theprodenvironment:1$ APP_RUNTIME_ENV=prod symfony console secrets:generate-keys
Re-add the OpenAI API key secret in the production vault but with its production value:
1
$ symfony console secrets:set OPENAI_API_KEY --env=prod
The last step is to send the decryption key to Upsun by setting a sensitive variable:
1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:SYMFONY_DECRYPTION_SECRET --value=`php -r 'echo base64_encode(include("config/secrets/prod/prod.decrypt.private.php"));'`
You can add and commit all files; the decryption key has been added in .gitignore automatically, so it will never be committed. For more safety, you can remove it from your local machine as it has been deployed now:
1
$ rm -f config/secrets/prod/prod.decrypt.private.php