This is a Symfony bundle, so as a Laravel developer your first job is a mental translation, not a code translation. Map the concepts before touching config:
| Laravel reflex | BetterAuth equivalent |
|---|---|
| Sanctum / Passport | PASETO v4.local access tokens + rotating refresh tokens |
| Fortify / Breeze actions | Generated auth controllers (CredentialsController, 2FA, magic-link) |
config/auth.php guards |
Symfony firewall + bundle mode (api / session / hybrid) |
php artisan migrate + published models |
Doctrine entities with app-owned persistence + better-auth:user-fields |
php artisan vendor:publish |
better-auth:publish-templates, better-auth:setup-features |
| Laravel scheduler cleanup | better-auth:cleanup:sessions, better-auth:cleanup:tokens |
Minimum steps
composer require betterauth/symfony-bundle:0.0.20 # pin exactly, pre-1.0
php bin/console better-auth:install
php bin/console better-auth:setup-features --preset=full --with-controllers --migrate
php bin/console debug:router | grep auth
php bin/console debug:config better_auth
Hard prerequisites before you start: PHP 8.4, Symfony 6.4/7/8, Doctrine ORM 3, MigrationsBundle 4. If your host or CI is on 8.2/8.3, stop here — this is not a "try it and see" package.
Where to look first, in this order:
docs/01-INSTALLATION.md, docs/02-CONFIGURATION.md — the config tree is the source of truth.docs/03-API-MODE.md vs 04-SESSION-MODE.md vs 05-HYBRID-MODE.md — pick your mode before generating controllers; better-auth:switch-mode exists but retrofitting is cheaper than migrating.docs/11-SECURITY.md — the production hardening checklist, plus changelog/0.0.20.md, which is the release you should actually read.First use case: an API-first / API Platform 4 backend for a SPA or mobile client. Run --preset=full in api mode: JSON login → PASETO access token in the Authorization header, refresh token in an httpOnly cookie, plus /auth/refresh, /auth/logout. Skip session mode until you have a server-rendered Symfony app.
Pin the version and treat the generated controllers as your code. --with-controllers writes controllers into your src/, not the vendor tree; you own them from that moment. Diff them on every upgrade instead of blindly regenerating — that's also your extension point: add throttling headers, audit logging, or tenant resolution in the generated controller or by decorating the service it delegates to.
Mode selection drives everything downstream:
api — stateless PASETO, refresh cookie, best fit for mobile/SPA. refresh_token is a real rotatable credential.session — Symfony session with a hashed token at rest; in 0.0.20 the response deliberately returns refresh_token: null. Do not wire client-side rotation logic that assumes a refresh token here.hybrid — both; useful when a SPA and a server-rendered admin share one backend. Costs the most config surface.Refresh rotation, correctly. Store the refresh token in an httpOnly cookie (cookie_secure/cookie_http_only default true, cookie_same_site lax). On 401, call refresh once per request via a queued interceptor, then retry. Rotation means a replayed old token should be treated as theft — surface replaced_by invalidation as a forced logout, not a silent retry loop.
2FA enrollment as a two-step action. Generate secret → return otpauth URI for QR → confirm code → enable. login() returns a challenge; login2fa() completes it. Secrets are AES-256-GCM encrypted at rest with a key derived from %better_auth.secret%, so enrollment and verification must hit instances sharing the same secret.
Passwordless + OAuth. Magic-link and email-verification endpoints validate callbackUrl against your trusted frontend origin and return 400 on a foreign host — so configure that origin explicitly per environment (https://app.example.com in prod, http://localhost:3000 in dev). For OAuth, verify the account-linking path handles Google's verified_email field (fixed in 0.0.10); linking an existing email is the highest-risk flow, so test it manually.
Application-owned persistence. Start with the generated entities, then extend via better-auth:user-fields rather than editing vendor mapping. If you need custom storage, implement the bundle's repository interfaces — the Doctrine session and TOTP repositories are swappable services, which is also how you'd point at a read/write split or a cache-backed session store.
Operationalize the cleanup commands in Symfony Scheduler (or cron):
$schedule->add(RecurringMessage::every('1 hour', new RunCommand('better-auth:cleanup:sessions')));
$schedule->add(RecurringMessage::every('1 day', new RunCommand('better-auth:cleanup:tokens')));
Testing. Use SQLite for functional tests (sqlite:///%kernel.project_dir%/var/test.db) — it's covered in CI. Reset the rate limiter between tests (use array cache/lock in test env) or your login tests will hit the new 5-attempts/15-minutes limit and give you confusing 429s. Assert on the hashed-at-rest invariants: session tokens are stored as sha256(token), TOTP seeds carry an enc:v1: prefix.
Pre-1.0 with 4 stars and zero dependents. Every release so far is 0.0.x. Pin exact versions (:0.0.20) in composer.json, never ^0.0, and read changelog/ before bumping. Budget for self-support: fork access and the ability to patch a controller is realistic; waiting on upstream is not.
PHP 8.4 is a hard floor — newer than most Laravel shops run. Check CI images, Docker base, and any shared-hosting target before committing.
Secret handling is the #1 production footgun. BETTER_AUTH_SECRET must be long, stable, and identical across every instance. Rotating it makes all encrypted TOTP secrets unreadable, permanently locking out 2FA users. Also note that secret-length validation happens at runtime in TokenService, not at container compile time (the compile-time check was removed in 0.0.13 because Symfony's ValidateEnvPlaceholdersPass substitutes short placeholders for %env(...)%). Consequence: the app boots fine with a bad secret and fails on the first login. Add your own boot-time smoke test hitting a token endpoint in staging.
Undeclared config keys silently vanish. 0.0.20 shipped cookie_secure/cookie_http_only/cookie_same_site in the recipe but they weren't in the configuration tree, so they could be dropped without error. Always verify with php bin/console debug:config better_auth after editing YAML. In local dev over plain HTTP, cookie_secure: true means the refresh cookie is never set — flip it per-env, don't disable it globally.
Rate limiting is now in front of the password and TOTP checks (5 / 15 min per IP+email). Two implications: the limiter needs a real shared store (Redis/DB) behind multiple app servers, and behind a proxy you must configure trusted proxies or every request looks like the load balancer's IP and you'll globally lock out logins.
Don't enable QueryParameterTokenExtractor. It's intentionally excluded from the default ChainTokenExtractor — tokens in URLs leak into access logs, Referer headers, and browser history. If a third party asks for ?token=, make them use the header.
Legacy data migrates itself, on first access. After upgrading to 0.0.20, plaintext session rows are rehashed and plaintext TOTP secrets re-encrypted in place. This is by design (no composer hook, no migration step), but it means: mixed-version deploys during rollout will behave inconsistently, and if you rotate the secret before upgrading, the re-encryption writes garbage. Upgrade code first, confirm, then touch secrets — never the reverse.
The better-auth:* command namespace is inconsistent with Symfony's usual app:/vendor-prefixed convention; debug:router | grep auth is more reliable than guessing route names. Same for controllers: read the generated routes rather than assuming /login vs /auth/login.
Watch the dependency floor. Doctrine ORM ^3 and MigrationsBundle ^4 will collide with older bundles in a legacy app. Check composer why-not doctrine/orm ^3 before you promise a migration timeline — this bundle is a modernization forcing function, not a drop-in for an app still on ORM 2.
Logging hygiene is handled for you — emails are masked (ke***@domain) in failure logs. Keep it that way: don't log request bodies around these endpoints or you'll undo the effort. better-auth:setup:logging wires the channel; use it rather than rolling your own monolog handler.
How can I help you explore Laravel packages today?