This guide covers advanced customization scenarios: overriding controllers, customizing response formats, renaming routes, and API versioning.
Add a prefix to all BetterAuth routes:
# config/routes.yaml
better_auth:
resource: '[@BetterAuthBundle](https://github.com/BetterAuthBundle)/config/routes.yaml'
prefix: /api/v1
Result:
/auth/login → /api/v1/auth/login/auth/register → /api/v1/auth/registerTo completely replace bundle routes with your own:
# config/routes.yaml
# Comment out or remove bundle routes
# better_auth:
# resource: '[@BetterAuthBundle](https://github.com/BetterAuthBundle)/config/routes.yaml'
# Load your custom controllers instead
app_auth:
resource:
path: ../src/Controller/Auth/
namespace: App\Controller\Auth
type: attribute
To rename /auth/login to /api/signin:
<?php
// src/Controller/Auth/SignInController.php
declare(strict_types=1);
namespace App\Controller\Auth;
use BetterAuth\Core\AuthManager;
use BetterAuth\Providers\TotpProvider\TotpProvider;
use BetterAuth\Symfony\Controller\CredentialsController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class SignInController extends CredentialsController
{
public function __construct(
AuthManager $authManager,
TotpProvider $totpProvider,
) {
parent::__construct($authManager, $totpProvider);
}
/**
* Custom login endpoint with renamed route.
*/
#[Route('/api/signin', name: 'app_signin', methods: ['POST'])]
public function signin(Request $request): JsonResponse
{
return $this->login($request);
}
/**
* Keep register at a different path too.
*/
#[Route('/api/signup', name: 'app_signup', methods: ['POST'])]
public function signup(Request $request): JsonResponse
{
return $this->register($request);
}
}
# config/routes.yaml
# Disable bundle credentials routes, keep others
better_auth:
resource: '[@BetterAuthBundle](https://github.com/BetterAuthBundle)/config/routes.yaml'
exclude: '../src/Controller/CredentialsController.php'
# Load custom signin controller
app_auth:
resource:
path: ../src/Controller/Auth/
namespace: App\Controller\Auth
type: attribute
The cleanest approach for API versioning:
# config/routes.yaml
# API v2 - New BetterAuth endpoints
api_v2_auth:
resource: '[@BetterAuthBundle](https://github.com/BetterAuthBundle)/config/routes.yaml'
prefix: /api/v2
# API v1 - Legacy endpoints (deprecated)
api_v1_auth:
resource: ../config/routes/legacy_v1.yaml
prefix: /api/v1
Result:
POST /api/v2/auth/loginPOST /api/v1/auth/loginFor clients that specify version via header:
<?php
// src/EventListener/ApiVersionListener.php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
#[AsEventListener(event: KernelEvents::REQUEST, priority: 100)]
class ApiVersionListener
{
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
// Extract version from Accept header
// Example: Accept: application/vnd.myapp.v2+json
$accept = $request->headers->get('Accept', '');
if (preg_match('/application\/vnd\.myapp\.v(\d+)\+json/', $accept, $matches)) {
$request->attributes->set('_api_version', (int) $matches[1]);
} else {
// Default to v2
$request->attributes->set('_api_version', 2);
}
}
}
Extend the bundle controller and override specific methods:
<?php
// src/Controller/Auth/CustomCredentialsController.php
declare(strict_types=1);
namespace App\Controller\Auth;
use BetterAuth\Core\AuthManager;
use BetterAuth\Core\Entities\User;
use BetterAuth\Providers\TotpProvider\TotpProvider;
use BetterAuth\Symfony\Controller\CredentialsController;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[Route('/auth')]
class CustomCredentialsController extends CredentialsController
{
public function __construct(
AuthManager $authManager,
TotpProvider $totpProvider,
private readonly LoggerInterface $logger,
private readonly EventDispatcherInterface $dispatcher,
) {
parent::__construct($authManager, $totpProvider);
}
#[Route('/register', name: 'app_register', methods: ['POST'])]
public function register(Request $request): JsonResponse
{
// Pre-processing: custom validation
$data = $request->toArray();
if (!$this->isValidEmailDomain($data['email'] ?? '')) {
return $this->json([
'error' => 'registration_blocked',
'message' => 'Registration is only allowed for company emails.',
], 403);
}
$this->logger->info('Registration attempt', [
'email' => $data['email'],
'ip' => $request->getClientIp(),
]);
// Call parent implementation
$response = parent::register($request);
// Post-processing: analytics, webhooks, etc.
if ($response->getStatusCode() === 201) {
$this->dispatcher->dispatch(new UserRegisteredEvent($data['email']));
}
return $response;
}
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$this->logger->info('Login attempt', [
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
]);
return parent::login($request);
}
private function isValidEmailDomain(string $email): bool
{
$allowedDomains = ['company.com', 'company.fr'];
$domain = substr(strrchr($email, '@'), 1);
return in_array($domain, $allowedDomains, true);
}
}
Wrap the original controller without inheritance:
# config/services.yaml
services:
App\Controller\Auth\DecoratedCredentialsController:
decorates: BetterAuth\Symfony\Controller\CredentialsController
arguments:
$inner: '@.inner'
$logger: '[@logger](https://github.com/logger)'
$rateLimiter: '[@limiter](https://github.com/limiter).login'
<?php
// src/Controller/Auth/DecoratedCredentialsController.php
declare(strict_types=1);
namespace App\Controller\Auth;
use BetterAuth\Symfony\Controller\CredentialsController;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
class DecoratedCredentialsController
{
public function __construct(
private readonly CredentialsController $inner,
private readonly LoggerInterface $logger,
private readonly RateLimiterFactory $rateLimiter,
) {
}
public function login(Request $request): JsonResponse
{
// Rate limiting
$limiter = $this->rateLimiter->create($request->getClientIp());
if (!$limiter->consume()->isAccepted()) {
return new JsonResponse([
'error' => 'rate_limit_exceeded',
'message' => 'Too many login attempts. Please try again later.',
'retry_after' => 60,
], 429);
}
$this->logger->info('Login attempt', [
'ip' => $request->getClientIp(),
]);
return $this->inner->login($request);
}
public function register(Request $request): JsonResponse
{
return $this->inner->register($request);
}
}
Build your own controller using BetterAuth services:
<?php
// src/Controller/Auth/CustomAuthController.php
declare(strict_types=1);
namespace App\Controller\Auth;
use BetterAuth\Core\AuthManager;
use BetterAuth\Core\Entities\User;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/auth', name: 'api_auth_')]
class CustomAuthController extends AbstractController
{
public function __construct(
private readonly AuthManager $authManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/login', name: 'login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$data = $request->toArray();
// Custom validation
if (empty($data['email']) || empty($data['password'])) {
return $this->json([
'success' => false,
'error' => 'Email and password are required.',
], 400);
}
try {
$result = $this->authManager->signIn(
email: $data['email'],
password: $data['password'],
ipAddress: $request->getClientIp() ?? '127.0.0.1',
userAgent: $request->headers->get('User-Agent') ?? 'Unknown',
);
$user = $result['user'];
// Custom response format
return $this->json([
'success' => true,
'data' => [
'token' => $result['access_token'],
'refreshToken' => $result['refresh_token'],
'expiresAt' => date('c', time() + ($result['expires_in'] ?? 3600)),
],
'user' => $this->formatUser($user),
]);
} catch (\Exception $e) {
$this->logger->warning('Login failed', [
'email' => $data['email'],
'error' => $e->getMessage(),
]);
return $this->json([
'success' => false,
'error' => 'Invalid credentials.',
], 401);
}
}
private function formatUser(User $user): array
{
return [
'id' => $user->getId(),
'email' => $user->getEmail(),
'displayName' => $user->getName(),
'verified' => $user->isEmailVerified(),
'memberSince' => $user->getCreatedAt()->format('Y-m-d'),
];
}
}
The bundle uses AuthResponseTrait with two main methods:
// Default formatUser() output
[
'id' => 'uuid-here',
'email' => 'user@example.com',
'name' => 'John Doe',
'emailVerified' => true,
'createdAt' => '2024-01-15T10:30:00+00:00',
'updatedAt' => '2024-01-15T10:30:00+00:00',
]
// Default formatAuthResponse() output
[
'access_token' => 'v4.local.xxx...',
'refresh_token' => 'xxx...',
'expires_in' => 3600,
'token_type' => 'Bearer',
'user' => [...],
]
Create your own trait to override response formatting:
<?php
// src/Controller/Trait/CustomAuthResponseTrait.php
declare(strict_types=1);
namespace App\Controller\Trait;
use BetterAuth\Core\Entities\User;
trait CustomAuthResponseTrait
{
protected function formatUser(User $user): array
{
return [
'id' => $user->getId(),
'email' => $user->getEmail(),
'profile' => [
'displayName' => $user->getName(),
'avatar' => $user->getAvatar(),
'initials' => $this->getInitials($user->getName()),
],
'status' => [
'verified' => $user->isEmailVerified(),
'active' => true,
],
'timestamps' => [
'createdAt' => $user->getCreatedAt()->format('c'),
'updatedAt' => $user->getUpdatedAt()->format('c'),
],
];
}
protected function formatAuthResponse(array $result, User $user): array
{
$expiresIn = $result['expires_in'] ?? 3600;
return [
'auth' => [
'accessToken' => $result['access_token'],
'refreshToken' => $result['refresh_token'],
'tokenType' => 'Bearer',
'expiresIn' => $expiresIn,
'expiresAt' => date('c', time() + $expiresIn),
],
'user' => $this->formatUser($user),
'meta' => [
'serverTime' => date('c'),
'apiVersion' => 'v2',
],
];
}
private function getInitials(?string $name): string
{
if (empty($name)) {
return '??';
}
$parts = explode(' ', $name);
$initials = '';
foreach (array_slice($parts, 0, 2) as $part) {
$initials .= strtoupper(substr($part, 0, 1));
}
return $initials;
}
}
Use it in your controller:
<?php
// src/Controller/Auth/V2CredentialsController.php
declare(strict_types=1);
namespace App\Controller\Auth;
use App\Controller\Trait\CustomAuthResponseTrait;
use BetterAuth\Core\AuthManager;
use BetterAuth\Providers\TotpProvider\TotpProvider;
use BetterAuth\Symfony\Controller\CredentialsController;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/v2/auth')]
class V2CredentialsController extends CredentialsController
{
use CustomAuthResponseTrait;
public function __construct(
AuthManager $authManager,
TotpProvider $totpProvider,
) {
parent::__construct($authManager, $totpProvider);
}
}
Complete example for migrating from legacy API to BetterAuth.
src/Controller/
├── Auth/
│ ├── V1/
│ │ └── LegacyAuthController.php # Deprecated, for backwards compatibility
│ └── V2/
│ ├── AuthController.php # New BetterAuth-based
│ └── Trait/
│ └── V2ResponseTrait.php
config/
├── routes.yaml
└── packages/
└── security.yaml
# config/routes.yaml
# API v2 - New endpoints (recommended)
api_v2_auth:
resource:
path: ../src/Controller/Auth/V2/
namespace: App\Controller\Auth\V2
type: attribute
prefix: /api/v2
# API v1 - Legacy endpoints (deprecated, remove after migration)
api_v1_auth:
resource:
path: ../src/Controller/Auth/V1/
namespace: App\Controller\Auth\V1
type: attribute
prefix: /api/v1
# Other app routes
app:
resource:
path: ../src/Controller/
namespace: App\Controller
exclude: ../src/Controller/Auth/
type: attribute
# config/packages/security.yaml
security:
providers:
better_auth:
id: BetterAuth\Symfony\Security\BetterAuthUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# API v2 firewall
api_v2:
pattern: ^/api/v2
stateless: true
provider: better_auth
custom_authenticators:
- BetterAuth\Symfony\Security\BetterAuthAuthenticator
# API v1 firewall (legacy)
api_v1:
pattern: ^/api/v1
stateless: true
provider: better_auth
custom_authenticators:
- BetterAuth\Symfony\Security\BetterAuthAuthenticator
access_control:
# Public endpoints
- { path: ^/api/v[12]/auth/(login|register|password), roles: PUBLIC_ACCESS }
# Protected endpoints
- { path: ^/api/v[12], roles: ROLE_USER }
<?php
// src/Controller/Auth/V1/LegacyAuthController.php
declare(strict_types=1);
namespace App\Controller\Auth\V1;
use BetterAuth\Core\AuthManager;
use BetterAuth\Core\Entities\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* [@deprecated](https://github.com/deprecated) Use /api/v2/auth endpoints instead.
*/
#[Route('/auth', name: 'api_v1_auth_')]
class LegacyAuthController extends AbstractController
{
public function __construct(
private readonly AuthManager $authManager,
) {
}
#[Route('/login', name: 'login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$response = new JsonResponse();
$response->headers->set('X-Deprecated', 'true');
$response->headers->set('X-Deprecated-Message', 'Use /api/v2/auth/login instead');
$data = $request->toArray();
try {
$result = $this->authManager->signIn(
email: $data['email'] ?? $data['username'] ?? '',
password: $data['password'] ?? '',
ipAddress: $request->getClientIp() ?? '127.0.0.1',
userAgent: $request->headers->get('User-Agent') ?? 'Unknown',
);
// V1 response format (legacy)
return $this->json([
'status' => 'success',
'token' => $result['access_token'],
'user' => [
'id' => $result['user']->getId(),
'username' => $result['user']->getEmail(),
'email' => $result['user']->getEmail(),
],
], headers: [
'X-Deprecated' => 'true',
'X-Deprecated-Message' => 'Use /api/v2/auth/login instead',
]);
} catch (\Exception $e) {
return $this->json([
'status' => 'error',
'message' => 'Invalid credentials',
], 401, [
'X-Deprecated' => 'true',
]);
}
}
}
<?php
// src/Controller/Auth/V2/AuthController.php
declare(strict_types=1);
namespace App\Controller\Auth\V2;
use BetterAuth\Core\AuthManager;
use BetterAuth\Providers\TotpProvider\TotpProvider;
use BetterAuth\Symfony\Controller\CredentialsController;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/auth', name: 'api_v2_auth_')]
class AuthController extends CredentialsController
{
public function __construct(
AuthManager $authManager,
TotpProvider $totpProvider,
) {
parent::__construct($authManager, $totpProvider);
}
// Inherits all methods from CredentialsController:
// - POST /auth/register
// - POST /auth/login
// - POST /auth/login/2fa
}
Routes prefixed by tenant identifier.
# config/routes.yaml
# Tenant-specific auth routes
tenant_auth:
resource:
path: ../src/Controller/Tenant/
namespace: App\Controller\Tenant
type: attribute
<?php
// src/Controller/Tenant/TenantAuthController.php
declare(strict_types=1);
namespace App\Controller\Tenant;
use App\Service\TenantResolver;
use BetterAuth\Core\AuthManager;
use BetterAuth\Core\Entities\User;
use BetterAuth\Providers\TotpProvider\TotpProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/{tenant}/auth', name: 'tenant_auth_', requirements: ['tenant' => '[a-z0-9-]+'])]
class TenantAuthController extends AbstractController
{
public function __construct(
private readonly AuthManager $authManager,
private readonly TotpProvider $totpProvider,
private readonly TenantResolver $tenantResolver,
) {
}
#[Route('/login', name: 'login', methods: ['POST'])]
public function login(string $tenant, Request $request): JsonResponse
{
// Resolve and validate tenant
$tenantConfig = $this->tenantResolver->resolve($tenant);
if ($tenantConfig === null) {
throw new NotFoundHttpException("Tenant '$tenant' not found.");
}
$data = $request->toArray();
try {
$result = $this->authManager->signIn(
email: $data['email'],
password: $data['password'],
ipAddress: $request->getClientIp() ?? '127.0.0.1',
userAgent: $request->headers->get('User-Agent') ?? 'Unknown',
);
$user = $result['user'];
// Verify user belongs to this tenant
if (!$this->userBelongsToTenant($user, $tenant)) {
return $this->json([
'error' => 'access_denied',
'message' => 'User does not belong to this tenant.',...
How can I help you explore Laravel packages today?