Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Bastion Laravel Package

juststeveking/laravel-bastion

Laravel Bastion gives your Laravel application a fortified foundation, bundling the conventions and groundwork you'd otherwise rebuild for every project so you can focus on shipping features. Lightweight, framework-friendly, and simple to drop into new or existing apps.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish.

composer require juststeveking/laravel-bastion
php artisan vendor:publish --tag="bastion-config"
php artisan vendor:publish --tag="bastion-migrations"
php artisan migrate

The package ships a config file (guard name, model, table, environment handling) and a token table migration. Read both published files before migrating — the migration is where you confirm the storage strategy (hashed value, prefix/index column, expiry, revoked-at), and the config is where environment isolation is declared.

Where to look first, in order.

  1. config/bastion.php — guard name, token model, hashing, environment rules.
  2. The published migration — is the token stored hashed? Is the lookup column indexed?
  3. vendor/juststeveking/laravel-bastion/src/ — the guard and the middleware. This is a small package; read it in fifteen minutes. The README makes security claims ("environment isolation, granular scopes") that you should confirm in the guard's __invoke/user() path rather than assume.
  4. Your config/auth.php — add the guard entry the package expects.

Wire the guard and middleware.

// config/auth.php
'guards' => [
    'bastion' => [
        'driver' => 'bastion',
        'provider' => 'users', // or whatever the package's guard expects
    ],
],

Laravel 11/12 (bootstrap/app.php):

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'bastion' => \JustSteveKing\Bastion\Http\Middleware\Authenticate::class,
    ]);
})

Laravel 10: register the same alias in App\Http\Kernel::$middlewareAliases.

First use case — a partner API key. Attach the package's token trait to the model that owns the credential (usually User, or a dedicated Organization/Integration model — see below), mint a key, protect one route group:

$token = $user->createToken('acme-prod', ['orders:read'], environment: 'live');

return response()->json(['token' => $token->plainTextToken]); // shown once
Route::middleware(['bastion:orders:read'])->get('/v1/orders', OrderController::class);

If that round-trip works — mint, call, 200; remove scope, 403; revoke, 401 — you have completed the spike that matters.

Implementation Patterns

Wrap issuance in an action, never inline in a controller. Token creation is the one place where plaintext exists; keep it in a single class that also enforces your policy (max keys per owner, naming conventions, environment derived from the current app env, not from user input).

final class IssueApiKey
{
    public function __invoke(Model $owner, string $name, array $scopes): NewToken
    {
        abort_if($owner->tokens()->count() >= 10, 422, 'Key limit reached');

        return DB::transaction(fn () => $owner->createToken(
            name: $name,
            scopes: array_values(array_intersect($scopes, Scope::allowed())),
            environment: app()->environment('production') ? 'live' : 'test',
        ));
    }
}

The array_intersect against a hardcoded allowlist is the single cheapest defence against scope-escalation bugs — never pass user-supplied scopes straight through.

Scope taxonomy: resource:action, checked at the route group, bridged to Gates. Keep scopes coarse enough to be documented on a pricing page and fine enough to matter: orders:read, orders:write, webhooks:manage. For anything already guarded by policies, bridge once rather than duplicating checks:

// AppServiceProvider::boot()
Gate::before(function ($user, string $ability, array $args = []) {
    if (! $user instanceof ApiConsumer) {
        return null; // fall through to normal policies
    }

    return $user->currentAccessToken()?->can($ability) ?: null;
});

This stops a second, divergent authorization layer from growing next to your policies — the most common failure mode when adding key auth to an existing app.

A dedicated authenticatable model beats overloading User. In most products the owner of an API key is an integration or organization, not a human. Point the token trait at an ApiConsumer (or Integration) model and use a morph relation back to the tenant. Tenant resolution then becomes "the token's tokenable is the tenant," which is exactly what you want in tenanted middleware:

Route::middleware(['bastion', 'tenant.from-token'])->group(function () {
    Route::get('/v1/orders', ...);
});

Coexistence with Sanctum, not migration from it. Run two guards on two route prefixes (/api/* for Sanctum sessions, /v1/* for keys). Never stack auth:sanctum and bastion on the same route — ambiguous guard resolution is how endpoints end up either open or permanently 401. For an external-consumer migration, issue Bastion keys alongside existing tokens, run both paths for 60–90 days, then revoke.

Rotation as a first-class command. The package gives you revoke; you own rotate. Implement it as create-then-expire-old with an overlap window:

// php artisan bastion:rotate --days=30
$new = $this->issue($token->tokenable, $token->name, $token->scopes);
$token->update(['expires_at' => now()->addDays(30)]);

A hard revoke with no overlap will page you the day a partner rotates.

Test the security paths, not the happy path. Four tests carry most of the value:

  • key without the required scope → 403, not 401
  • revoked key → 401
  • live key against a test-configured app (and the inverse) → rejected — write this one first, and write it as a negative test against the guard, because "environment isolation" is the claim most likely to be convention rather than enforcement
  • expired key → 401

Use $this->actingAs($consumer, 'bastion') for the fast path and real HTTP calls with the plaintext header for the isolation test.

Cache the scope lookup, with revocation invalidation. One keyed read per request is fine; the moment the guard loads the tokenable, scopes, and tenant in three queries on a hot endpoint, cache token_id → {scopes, tenant_id} for 60s — and flush explicitly on revoke.

Gotchas and Tips

Confirm you are assessing the right package. The repository's own description and the packaged docs describe two different products (invitation/onboarding vs. API bearer-token auth). Before any integration work, open src/ and confirm which one the release actually is. Everything below assumes the API-token implementation.

Environment isolation may be advisory. Test it adversarially: mint in local, flip APP_ENV=production, and replay. If the key is accepted, the isolation is at mint time only — add your own guard-level assertion (compare $token->environment to a config-derived value in a middleware that runs before the package's), and treat the README claim as marketing until proven otherwise.

Version 0.0.x means every upgrade is potentially breaking. Pin the exact version in composer.json ("juststeveking/laravel-bastion": "0.0.2"). Do not use ^0.0. Read the diff before bumping; there is no ecosystem pressure that will surface a breaking change for you.

Plaintext is shown once. There is no "reveal" — by design. Model this in your UI (a copy-to-clipboard modal with a warning), and make "lost key → revoke and reissue" a documented flow so support doesn't improvise.

config:cache and the guard. The guard name is resolved from config at boot. If you add the guard entry after running config:cache in a deploy, you get a confusing Auth guard [bastion] is not defined. Clear and rebuild config in the deploy step, and make sure the package config is present in the cached config (don't publish it into a path that the deploy excludes).

Index the lookup column. If the migration ships a unique index on the hash/prefix column, leave it alone; if you add your own unique constraint on name for tidiness, expect failures when two tenants both call a key "Production". Verify the index exists on the column the guard actually queries — a missing index here is a full-table scan on every authenticated request.

Last-used writes. If the package stamps last_used_at per request, that's a write on every API call. On high-traffic endpoints, throttle it (update at most once per five minutes via a cache lock) or the table becomes a write hotspot.

Don't let revocation outlive your cache. Any TTL you add to the token lookup becomes the window in which a revoked key still works. Either flush on revoke or keep the TTL short and say so in your incident runbook.

Log hygiene. Ensure Authorization: Bearer … is redacted before it hits request logging or an APM. Laravel's default request logging won't do this for you if you log the full request.

Error semantics matter to consumers. 401 = no valid credential; 403 = valid credential, insufficient scope. Consumers cannot debug a partner integration if a missing scope returns 401 — they will regenerate keys uselessly for a day.

Fork contingency. With ~109 stars and a single maintainer, fork or vendor is a realistic path and the codebase is small enough to do it in a day. Keep issuance behind your IssueApiKey action so swapping the implementation touches one class, not every controller.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings