betterauth/multimodal-php
Framework-agnostic PHP authentication with session, Paseto V4 token, or hybrid modes. Includes OAuth social login, OpenID Connect, magic links, TOTP 2FA, risk-based session scoring, multi-tenancy, plugins, and PDO storage.
Read this first: betterauth/multimodal-php is archived and deprecated — it was merged into betterauth/symfony-bundle v1. There is no Laravel bundle, no service provider, no config file, no facade, and no guard. Everything below is manual wiring you own. For a greenfield Laravel app, the honest recommendation is Sanctum/Fortify. The instructions below are for teams that already have it in composer.json, or that need the specific features (Paseto V4, OIDC server, adaptive session risk scoring, plugin hooks) badly enough to pay the integration tax.
Minimal steps if you proceed:
composer require betterauth/multimodal-php
Then, in order, read:
src/Interfaces/ (28 interfaces) — this is the real API surface. Any repository you don't like, you replace.src/Core/Config/AuthConfig.php — the only "config file" you get. Map it from config/betterauth.php yourself.src/Core/AuthManager.php — the façade you'll bind into the container.src/Storage/Pdo/ — note the MySQL/MariaDB DDL caveat before you plan tests.First use case — session-mode login for a Blade app:
// app/Providers/BetterAuthServiceProvider.php
public function register(): void
{
$this->app->singleton(AuthConfig::class, fn () => AuthConfig::forHybrid(
config('betterauth.secret') // >= 32 chars, env-backed
));
$pdo = fn () => $this->app['db']->connection()->getPdo();
$this->app->singleton(SessionAuthManager::class, fn () => new SessionAuthManager(
new PdoUserRepository($pdo()),
new SessionService(new PdoSessionRepository($pdo()), $this->app->make(AuthConfig::class)),
new PasswordHasher(),
));
$this->app->singleton(AuthManager::class, fn ($app) => new AuthManager(
$app->make(AuthConfig::class),
sessionAuth: $app->make(SessionAuthManager::class),
));
}
// app/Http/Controllers/Auth/LoginController.php
public function store(LoginRequest $request, AuthManager $auth)
{
$result = $auth->signIn($request->email, $request->password); // throws/returns error on bad creds
$request->session()->put('ba_session', $result['session']->getToken());
$request->session()->put('ba_session_id', $result['session']->getId()); // opaque, for revocation
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
That's the whole first use case: bind, sign in, stash the token in the Laravel session, validate on each request. Nothing else in Laravel knows about it.
Pick a mode per surface, not per app. forMonolith for Blade routes, forApi for /api/*, forHybrid only when the same login must serve both (it costs you a session write and a token mint on every sign-in). Bind two AuthManager instances under distinct keys rather than one hybrid instance.
Middleware is your guard. There is no Auth::user(). Write one middleware per mode:
public function handle(Request $request, Closure $next)
{
$token = $request->session()->get('ba_session');
if (!$token || !($user = $this->auth->validateSession($token))) {
return redirect()->route('login');
}
$request->attributes->set('ba_user', $user); // UserDto, not an Eloquent model
return $next($request);
}
For API routes, read $request->bearerToken() and call the token-mode validator on TokenAuthManager (confirm the exact method name in src/Core/TokenAuthManager.php — the README only shows signIn/refresh). Refresh via $auth->refresh($refreshToken) — rotation is atomic by design, so never re-issue manually.
Bridge to Eloquent by ID, not by model. signIn() returns a UserDto. Don't let it leak into controllers. Resolve your own User model (User::find($dto->getId())) once in the middleware and expose that; keep the DTO inside the auth layer. This is what makes the eventual rip-out survivable.
OAuth = two routes plus a session-held state.
$authUrl = $oauth->getAuthorizationUrl('google', ['scope' => 'email profile']);
session()->put('oauth_state', $authUrl['state']);
return redirect()->away($authUrl['url']);
// callback
$result = $oauth->handleCallback('google', $request->code, $redirectUri, $request->ip(),
$request->userAgent(), $request->state, session()->pull('oauth_state'));
Register the provider instances as singletons in the container; they're stateless and cheap to reuse.
Use Laravel Mail for magic links. Ship a class implementing MagicLinkStorageInterface-adjacent email sender over Mail::to(...)->send(...) instead of PdoEmailSender. sendMagicLink('email', $sender, $redirect) takes the sender as an argument precisely because the author knew you'd do this.
2FA is a two-phase login. Use the split primitives from 0.0.3: verifyCredentials() → redirect to a TOTP challenge → completeTwoFactorLogin($user, $totpProvider, $code). Note the 0.1.0 signature change: it now requires the TotpProvider and code. If you upgrade and your controller still calls the old signature, you get a fatal ArgumentCountError — annoying, but at least not a silent 2FA bypass.
Plugins are your best integration seam. Rather than polling library internals, register a plugin in boot() whose hooks dispatch real Laravel events and queue jobs:
$context->registerHook('user.logged_in', fn (array $data) =>
LoginSucceeded::dispatch($data['user_id'], request()->ip()));
$context->registerHook('session.created', fn (array $data) =>
RecordSessionJob::dispatch($data)->onQueue('audit'));
Hooks: user.created, user.logged_in, user.logged_out, token.created, session.created, oauth.linked.
Multi-tenancy by foreign key, not by adoption. The org/team/invitation repositories are a parallel data model. Let them own membership rows keyed on the library user id, and attach a tenant_id (org id) to your own Eloquent models via a global scope resolved in middleware. Don't try to make the org entity your tenant root.
Testing. RefreshDatabase doesn't know about these tables. Either invoke the repositories' createTable() in a base TestCase::setUp() (MySQL only) or maintain hand-written migrations. Add an integration test asserting validateSession() rejects after revokeSession($userId, $sessionId) — that's the security-critical path and it's cheap to cover.
composer audit output on this package as permanently frozen and track it manually.createTable/upserts are MySQL-flavored. Laravel's .env.testing default is SQLite :memory: — your test suite will fail on schema, not on logic. Either test against MySQL (slower, but honest) or implement the four or five interfaces you actually use over Eloquent.RateLimiter requires a PSR-6 cache; Laravel's Cache/Repository is PSR-16. Wrap it (symfony/cache Psr6CacheAdapter over Cache::store()), otherwise you're on allowInMemoryFallback: true, which under a multi-worker/queue setup is effectively no rate limiting. Same trap under Octane: per-worker memory means per-worker counters.Hash. This is Argon2id with its own cost settings and its own rehash-on-login. You cannot use Auth::attempt, Fortify, Breeze, or Jetstream. If you have legacy bcrypt users, verify in a test that the hasher's verify() accepts $2y$ hashes (it should, if it delegates to password_verify) before betting on auto-rehash migration — don't assume.tymon/jwt-auth, Passport, or any JWT-decoding middleware. Your API clients get an opaque-looking V4 local token; there is no public claim set to read in the frontend.HttpOnly/Secure/SameSite settings only matter if you're issuing its cookie yourself, which under Laravel you are not.revokeSession($userId, $sessionId) was changed in 0.0.3 to resolve via findById(), not the token, specifically so expired sessions stay revocable. Persist $session->getId() at login or you can't do "log out other devices".password_verify() and silently succeeds on unknown emails for magic links. A friendly "no account with that email" in your controller undoes it.emailVerified. If you build a "link this provider to my existing account" flow, gate it on that flag — this was a documented account-takeover fix.PluginInterface hooks (events/audit/queue), RateLimiterInterface (custom backing store), and RsaJwt signer + OidcProvider::getJwks() if you're acting as an SSO provider and need RS256 + a JWKS endpoint at /.well-known/jwks.json.AuthService that translates DTOs into your domain — then moving to Sanctum later is a week, not a rewrite.How can I help you explore Laravel packages today?