evozon-php/simple-bruteforce-bundle
A Symfony bundle that adds simple brute-force protection to your application, tracking failed login attempts and blocking or throttling repeated authentication failures to help defend against credential-guessing and password-spraying attacks.
Reality check first: this is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no config/bruteforce.php. You cannot composer require it into a Laravel app and get anything useful — the auto-discovery will simply not fire. A Laravel developer evaluating this should treat it as reference architecture for a failed-login counter, or as something you port, not install. If you are in a Symfony codebase, proceed below.
Minimal steps (Symfony):
composer require evozon-php/simple-bruteforce-bundle — check the composer.json constraints before you commit. A "Symfony 3+" bundle from this era typically caps at ^3.4|^4.0, which means it will not install cleanly on Symfony 5/6/7 without a fork and a composer.json bump.app/AppKernel.php registerBundles(); Symfony 4+ Flex → add to config/bundles.php.vendor/evozon-php/simple-bruteforce-bundle/DependencyInjection/Configuration.php. Do not trust the README; it is usually stale on a package like this. bin/console config:dump-reference evozon_php_simple_bruteforce (adjust the alias) gives you the authoritative keys — typically max_attempts, ban_time/ban_duration, and a storage/driver key.bin/console debug:container bruteforce and open Resources/config/services.xml. Whatever the manager is called, that is your entry point.bin/console debug:event-dispatcher security.authentication.failure — if the bundle ships a listener, it is almost certainly hanging off the security failure/success events.First use case: the login form. The mental model is identical to Laravel's RateLimiter::for('login', ...) plus the throttle middleware, except you wire it manually:
AuthenticationUtils / authenticate(), ask the manager isBanned($key) and short-circuit with a 429 (or re-render the form with a generic error).security.authentication.failure, record an attempt for that key.security.authentication.success, reset the counter for that key.If the bundle already subscribes to those two events, steps 2 and 3 are free and you only write step 1.
Pattern 1 — Event-driven, zero controller code. The cleanest integration: let the bundle's subscriber record failures and clear on success, and only add a guard in your login controller (or a small kernel.request listener scoped to _route === 'app_login'). This is the pattern to prefer because it also covers form login, JSON login, and any custom authenticator — the security events are authenticator-agnostic.
Pattern 2 — Explicit manager injection. Inject the manager (BruteForceManager / BruteForceHandler / whatever debug:container shows) into your controller and call the three primitives yourself. More code, but testable and obvious:
if ($this->bruteForce->isBanned($key)) {
return $this->render('security/login.html.twig', ['error' => 'Too many attempts.']);
}
// ... attempt auth ...
Reach for this when you have a non-standard login flow (API token endpoint, OTP verification, password reset) where the bundle's event listeners do not apply.
Pattern 3 — Annotation/attribute guarding. If your version ships a @BruteForceProtection-style annotation, it is applied at the controller-action level. It is convenient but coarse: it keys off the request, not the submitted username, so it degrades to IP throttling. Do not rely on it alone for a login form.
Pattern 4 — Composite keys. The single most important design decision. ip alone lets one attacker behind NAT lock out an office; username alone lets an attacker lock out any user they can name (a free DoS). Use $request->getClientIp() . '|' . $username for the ban decision, and optionally keep a looser IP-only counter as a second tier. Laravel's RateLimiter examples do exactly this and it is worth copying the shape.
Pattern 5 — Storage backend. If the bundle defaults to Doctrine, you get persistence across deploys and multiple app servers — good. If it defaults to session, it is a speed bump only: clear the cookie, reset the counter. If it offers a cache-pool driver, point it at Redis so a load-balanced pair shares state. Swapping storage is usually a service alias override in services.yaml, not a config flag.
Pattern 6 — Response semantics. Return 429 Too Many Requests with a Retry-After header computed from the ban expiry, for API endpoints. For HTML login, re-render the form with a message that does not distinguish "banned" from "wrong password" — otherwise you have built a username enumeration oracle. Keep timing roughly constant too.
Testing workflow. Functional test with KernelBrowser: POST bad credentials maxAttempts times, assert the next POST is rejected without hitting the user provider, then travel the clock (ClockMock::withClockMock() around the ban window) and assert you can try again. Assert the counter is cleared after a successful login — that is the regression that actually bites in production.
Laravel mapping, for context: RateLimiter::for('login', fn ($r) => Limit::perMinute(5)->by($r->input('email').$r->ip())) + ->middleware('throttle:login') gives you a sliding window with a Retry-After and no database. This bundle gives you a harder ban (block for N minutes after M failures) rather than a sliding rate limit. They are different shapes: throttling degrades, banning locks out. Decide which you want before adopting.
composer.json for modern Symfony. If the requirement is simply "lock out after N failed logins," ~80 lines of your own listener plus a unique-indexed counter table is often cheaper than the integration debt.getClientIp() returns the load balancer unless framework.trusted_proxies / trusted_headers are configured. Get this wrong and your counter is global across all users — the whole site locks out at once. Verify with a log line before you ship.isBanned()/addAttempt() implementations rather than guessing from config key names.maxAttempts. Does "5 attempts" mean the 5th fails or the 6th is blocked? Write the functional test that asserts the exact boundary; nobody gets this right from the docs.count = 4 and both write 5. If storage is Doctrine, the counter row needs an atomic upsert or a unique constraint with a retry — not read-modify-write in PHP.bin/console cache:clear. A stale container is the #1 cause of "my config isn't taking effect" in Symfony.services.yaml; (3) register your own listener on security.authentication.failure with a higher priority so it runs before the bundle's; (4) decorate the manager service (decorates:). Avoid editing vendor files — you will lose it on the next composer update.debug:container <name>, debug:event-dispatcher security.authentication.failure, debug:config <alias>, and a temporary dump() in the listener to confirm it fires at all. If attempts are not being recorded, 90% of the time the listener isn't registered or the event name is wrong for your Symfony version (security.authentication.failure vs the older security.authentication.failure on AuthenticationEvents).RateLimiter, or a login_attempts table with a composite unique index on (key, ip), a LoginThrottle service, and a ThrottleRequests-style middleware. You will get a better-maintained result than adopting a dormant Symfony 3 bundle through a compatibility shim.How can I help you explore Laravel packages today?