NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Auth for Laravel

The Authentication facade

Everything the HTTP layer does is available from PHP through RoundlyConsulting\Auth\Facades\Authentication (no Auth alias — it would collide with Laravel’s). Authentication::guard('clients') is one guard’s API:

use RoundlyConsulting\Auth\DataTransferObjects\PasswordCredentials;
use RoundlyConsulting\Auth\Facades\Authentication;
use RoundlyConsulting\Auth\Http\Resources\ChallengeResource;
use RoundlyConsulting\Auth\Http\Resources\TokenPairResource;

$guard = Authentication::guard('clients');

$result = $guard->attempt(
    new PasswordCredentials(identifier: $request->string('email')->toString(), password: $request->string('password')->toString()),
    $guard->contextFrom($request),
);

return $result->isAuthenticated()
    ? TokenPairResource::make($result->tokens)
    : ChallengeResource::make($result->challenge);   // continue with $guard->challenges()->complete(…)

Every other per-guard method on the facade runs on the default guard (authentication.default), so Authentication::twoFactor()->status($user) is Authentication::guard()->twoFactor()->status($user):

use RoundlyConsulting\Auth\DataTransferObjects\InvitationData;
use RoundlyConsulting\Auth\Enums\InvalidationReason;
use RoundlyConsulting\Auth\Facades\Authentication;

// Every per-guard method runs on the default guard (authentication.default)…
Authentication::twoFactor()->status($user);          // enabled, pending, recovery codes left, mode
Authentication::passwords()->set($user, 'n3w-Passphrase!', InvalidationReason::Security);
Authentication::invitations()->create(new InvitationData('[email protected]'))->url;   // needs invitations.enabled
Authentication::lock($user, seconds: 3600);

// …or on a named guard
Authentication::guard('clients')->twoFactor()->status($client);

// Manager-level
Authentication::guards();                            // ['users', 'clients']
Authentication::prune(days: 30);                     // PruneReport — what authentication:prune runs

Invitations are off by default: until the guard sets invitations.enabled = true, every invitations() call throws LoginMethodDisabled (a 404 method_disabled over HTTP).

Core verbs

Authentication::guard($name) returns a GuardContext; each method runs one container-resolved action for that guard:

MethodReturns
attempt(PasswordCredentials, SessionContext)LoginResult (tokens or challenge)
requestMagicLink($email, $ctx) / consumeMagicLink($token, $ctx)void / LoginResult
requestEmailOtp($email, $ctx) / verifyEmailOtp($email, $code, $ctx)void / LoginResult
passkeyLoginOptions($ctx) / loginWithPasskey($response, $ctx)RequestOptionsData / LoginResult
register(RegistrationData)RegistrationResult
issueTokens($account, $ctx, $method, $amr)TokenPair — host-vouched login (impersonation, SSO); fires TokensIssued
refresh($refreshToken, $ctx)TokenPair (the session’s previous access token stops working)
sessions($account, $currentSessionId)Collection<SessionData>
logout($account, $current) / logoutSession($account, $id) / logoutOthers($account, $current) / logoutEverywhere($account)— / — / int / int
invalidate($account, InvalidationReason, ?$keep, ?$ctx)?TokenPair (the re-issued pair under others)
disable($account, ?$reason) / enable($account)—
lock($account, ?$seconds) / unlock($account)CarbonImmutable / — (a lock notifies the owner, never logs out)
updateLocale($account, LocaleData)Account
activity($account, $perPage = 20)Paginated own LoginActivity
contextFrom(Request) / tokenFrom(Request)SessionContext / CurrentToken
name() / config() / accounts()string / GuardConfig / AccountRepository

Sub-contexts

Each area has its own sub-context — on a guard, or on the facade for the default guard ($a is an account):

Sub-contextMethods
twoFactor()status($a), start($a), confirm($a, $code, ?$current, ?$ctx), disable($a, ?$current, ?$ctx), regenerateRecoveryCodes($a, ?$current, ?$ctx)
passkeys()all($a), registrationOptions($a), register($a, $response, ?$name, ?$current, ?$ctx), rename($a, Passkey|int, $name), remove($a, Passkey|int, ?$current, ?$ctx)
passwords()set($a, $password, $reason), change($a, ChangePasswordData), requestReset($email, $ctx), reset(PasswordResetData), validate($password, ?$a, ?$email), rule(?$email)
email()sendVerification($a, ?$ctx), requestVerification($a, $ctx), resendVerification($email, $ctx), verify($tokenOrCode, ?$email, $ctx), requestChange($a, EmailChangeData), confirmChange($token, $ctx)
invitations()create(InvitationData) → InvitationLink, accept(AcceptInvitationData) → RegistrationResult, paginate(?$status, $perPage), preview($token), find($id), resend(Invitation|int), revoke(Invitation|int), link(Invitation|int)
reauthentication()methods($a), sendCode($a, $ctx), passkeyOptions($a, $current), confirm($a, ReauthenticationData), ensureRecent($a, $current, ?$seconds), ensureFor(SensitiveAction, $a, $current)
challenges()complete(ChallengeFactorData), passkeyOptions($token, $ctx), passkeyEnrolmentOptions($token, $ctx), startTwoFactorEnrolment($token, $ctx)

Plus Authentication::prune(?$days), guards(), routes($guard) and currentGuard().

Scoping

Every method that takes an account refuses one of another guard’s model (AuthenticationMisconfigured, “belongs to another guard”) before anything is written. Another guard’s invitation or challenge token, and another account’s passkey, are unknown (InvitationNotFound, ChallengeInvalid, PasskeyNotFound).

Acting for the signed-in user

Pass the caller’s token ($guard->tokenFrom($request)) as $current to the credential changes: its device is kept under others and the re-issued pair is returned — the client must swap to it. Leave $current out for admin and CLI calls. The HTTP layer also demands a recent re-authentication before the actions in reauthentication.required_for; PHP callers vouch for the user themselves, or run the same gate first:

use RoundlyConsulting\Auth\Enums\SensitiveAction;
use RoundlyConsulting\Auth\Facades\Authentication;

$guard   = Authentication::guard('users');
$current = $guard->tokenFrom($request);

$guard->reauthentication()->ensureFor(SensitiveAction::DisableTwoFactor, $user, $current);
$tokens = $guard->twoFactor()->disable($user, $current, $guard->contextFrom($request));   // swap the client to $tokens

passwords()->change() runs the gate itself for an account that is setting its first password. Use Authentication::…, not the lower packages’ model methods: $user->disableTwoFactor(), $user->regenerateTwoFactorRecoveryCodes(), Passkeys::for($user)->revoke(), $user->revokeAllSessions() or RefreshTokens::sessions($user)->revokeAll() change the credential but skip this package’s policy — the guard’s mode, the invalidation, the events and the owner’s notification.

Request-derived inputs

$guard->contextFrom($request) captures the IP, user agent, the device id from the X-Device-Id header, the device_name and timezone inputs and the negotiated locale; construct a SessionContext directly for jobs and tests: new SessionContext(ipAddress: '203.0.113.9', userAgent: 'CLI'). $guard->tokenFrom($request) reads the caller’s access token (jti, sid, auth_time, amr).

Handling a login result

A LoginResult is either authenticated (a TokenPair) or requires a challenge (a PendingChallenge). Complete the remaining steps with challenges()->complete():

use RoundlyConsulting\Auth\DataTransferObjects\ChallengeFactorData;
use RoundlyConsulting\Auth\DataTransferObjects\PasswordCredentials;
use RoundlyConsulting\Auth\Enums\FactorMethod;
use RoundlyConsulting\Auth\Facades\Authentication;

$guard   = Authentication::guard('users');
$context = $guard->contextFrom($request);

$result = $guard->attempt(new PasswordCredentials($request->input('identifier'), $request->input('password')), $context);

if ($result->requiresChallenge()) {
    $pending = $result->challenge;   // token, expiresAt, method, completed, remaining (ChallengeRequirement[]), attemptsLeft
    // … later, with the user's TOTP code:
    $result = $guard->challenges()->complete(new ChallengeFactorData(
        challengeToken: $pending->token,
        method: FactorMethod::Totp,
        context: $context,
        code: $request->input('code'),
    ));
}

$tokens = $result->tokens;   // accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, sessionId, accessTokenId, tokenType

To answer in the package’s JSON shape from your own controller, use LoginResponse:

use RoundlyConsulting\Auth\Http\Responses\LoginResponse;

// Render a LoginResult in the same shape as the package endpoints
return LoginResponse::make($result, $request);

Host-driven sessions

issueTokens() mints a session for a login your code vouches for — impersonation, an SSO callback, tests — and fires TokensIssued:

use RoundlyConsulting\Auth\Enums\AuthMethodReference;
use RoundlyConsulting\Auth\Enums\InvalidationReason;
use RoundlyConsulting\Auth\Enums\LoginMethod;

// SSO callback: the host vouches for the authentication (fires TokensIssued)
$pair = Authentication::guard('users')->issueTokens($user, $context, LoginMethod::Host, [AuthMethodReference::Mfa]);

// Incident response
Authentication::guard('users')->invalidate($user, InvalidationReason::Security);
Authentication::guard('users')->disable($user, 'chargeback fraud');

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.