stephenjude/filament-jetstream
Filament Jetstream is a Laravel starter kit built with native Filament panels, including auth, registration, 2FA, session management, profile editing, API tokens, and optional teams. Skip the boilerplate and start building features fast.
Check the version matrix before anything else. This package tracks Filament majors in lockstep: 2.x targets Filament 5 / Laravel 13, 1.4.x is the Filament 4 / Laravel 12–13 line, and 1.2.x is the older Filament 3-era track. Composer will usually resolve correctly, but if you're on a Filament 4 panel and blindly run composer require stephenjude/filament-jetstream, you can drag in a Filament 5 constraint and spend an hour untangling it. Pin explicitly:
composer require stephenjude/filament-jetstream:^1.4
Install into a clean tree. The package is a scaffolder, not a black-box runtime. It writes stubs, migrations, and a panel provider into your app, so commit (or stash) first and review the diff:
composer require stephenjude/filament-jetstream
php artisan filament-jetstream:install --teams --api
Drop --teams and --api if you don't need them — both are optional and adding them later means publishing extra migrations and traits by hand. The installer is interactive and will scaffold the User model, a panel provider, and the required migrations.
Where to look first, in order:
app/Providers/Filament/*PanelProvider.php — the generated ->plugin(JetstreamPlugin::make()) registration. This is the source of truth for what's enabled; there's no separate config/filament-jetstream.php to hunt for.app/Models/User.php — the traits (InteractsWIthProfile — note the typo in the upstream name, it's InteractsWIthProfile not InteractsWithProfile), contracts implemented, and the $hidden / $appends arrays.vendor/stephenjude/filament-jetstream/src/JetstreamPlugin.php — the fluent API is richer than the README shows. Read the method signatures once; it saves you guessing at argument names./profile and /teams in the browser — that's your working baseline.First use case: a new Laravel app where you need login, registration, profile editing, 2FA, and (optionally) teams — but you want it all inside a Filament panel rather than a separate Jetstream/Livewire frontend. You'll have a functional authenticated panel in well under an hour, which is where the real value sits versus hand-rolling Filament auth pages.
Treat every condition: closure as a feature flag. This is the single most important design idea in the package, and it's where day-to-day leverage lives. Conditions are evaluated per request, so scope features by plan, environment, role, or feature flag:
JetstreamPlugin::make()
->profilePhoto(condition: fn () => true, disk: 'public')
->deleteAccount(condition: fn () => auth()->user()->can('delete-account'))
->updatePassword(condition: fn () => true, Password::default())
->twoFactorAuthentication(
condition: fn () => auth()->check(),
forced: fn () => app()->isProduction() && auth()->user()->isStaff(),
requiresPassword: fn () => app()->isProduction(),
);
A false condition hides the section and blocks the underlying action, so this is a legitimate authorization surface — not just cosmetic. For anything security-relevant, still back it with a policy.
Existing app? Use the manual path, not the installer. The installer assumes a fresh scaffold. Add features piecewise instead:
# Profile
php artisan vendor:publish \
--tag=filament-jetstream-migrations \
--tag=passkeys-migrations \
--tag=filament-two-factor-authentication-migrations
# Teams (optional)
php artisan vendor:publish --tag=filament-jetstream-team-migration
Then wire the traits and contracts onto User:
class User extends Authenticatable implements HasAvatar, HasPasskeys, HasTenants
{
use InteractsWIthProfile;
use InteractsWithTeams; // teams only
use HasApiTokens; // API tokens only (Laravel\Sanctum)
protected $hidden = ['two_factor_recovery_codes', 'two_factor_secret'];
protected $appends = ['profile_photo_url'];
}
HasTenants + InteractsWithTeams only do half the job — your panel still needs ->tenant(Team::class) (and typically ->tenant(Team::class, slugAttribute: 'slug') if you want friendly URLs) before team switching behaves.
Custom models are first-class. In a real app your User is never the vanilla one, and teams almost always grow columns. Swap via the plugin rather than editing vendor code:
->configureUserModel(userModel: User::class)
->configureTeamModels(
teamModel: Team::class,
roleModel: Role::class,
membershipModel: Membership::class,
teamInvitationModel: TeamInvitation::class,
)
Extend the package models (Filament\Jetstream\Models\Team etc.) rather than rewriting them, so upstream fixes keep flowing.
Custom invitation flow. acceptTeamInvitation takes a callback receiving $invitationId. The default (defaultAcceptTeamInvitation()) is fine for most cases; override it when you need to log the acceptance, assign a default role, or redirect to a specific panel page after joining.
API tokens: define abilities, then enforce them. The permissions closure feeds the token-creation UI's ability checkboxes. Whatever you list there becomes Sanctum abilities, so protect your API routes with the standard middleware:
Route::middleware(['auth:sanctum', 'abilities:read,create'])->group(...);
Keep the permissions list identical to your actual ability vocabulary — a mismatch between UI checkboxes and abilities: middleware is the most common token bug.
Extending the profile page. For extra profile fields, don't fork the package page. Add your own Filament page (or a custom form on a separate Profile extension) that uses the same InteractsWIthProfile trait for photo handling, and leave the package page to handle the sensitive bits (password, 2FA, session logout). Two pages, one model, no merge conflicts.
Test the flows you can't eyeball. 2FA enforcement, session invalidation, and account deletion are all stateful and easy to break on upgrade. A Pest test that hits /profile, confirms two_factor_secret never appears in serialized output, and exercises the delete-account confirmation modal pays for itself the first time you bump a minor version.
You own the generated code — and it will drift. Everything the installer writes is yours to edit, which means upstream improvements to stubs won't reach you. After every package upgrade, diff your User model and panel provider against the current stubs in vendor/. Keep a short note of which generated files you've modified.
The version history is your upgrade checklist. Concrete fixes worth knowing: 1.2.9 fixed the delete-account flow and replaced deprecated modal form usage; 1.0.1 made form formats compatible with php artisan optimize; 0.0.13 fixed duplicate team creation on registration; 0.0.10 fixed current_team_id not updating; 1.2.8 reverted tenant slug support for friendly URLs. If you're pinning below 1.2.9, you're shipping a known-broken delete flow.
Closures must stay cheap and side-effect free. Conditions run on render, sometimes multiple times per request. Don't put a query, an HTTP call, or now()-dependent mutable state in one — and be careful with closures that capture $this from a request-scoped object.
The forced 2FA trap. forced: fn () => app()->isProduction() is the README's example and it's a footgun in local dev if you flip APP_ENV while debugging — you'll lock yourself into a 2FA challenge with no enrolled device. Gate it on something you can toggle, and always test the recovery-code path before shipping.
Leaking the 2FA secret is a real vulnerability, not a style nit. two_factor_secret and two_factor_recovery_codes must be in $hidden. The manual-install path asks you to add them; if you skip that line during a retrofit, you'll serialize secrets in every API response that returns a User. Verify with a quick dd($user->toArray()) on a throwaway route.
Profile photos need three things to line up: the disk: argument ('public' typically), php artisan storage:link, and a correct APP_URL. A wrong APP_URL produces broken image URLs that look like a package bug and aren't. If you're on S3, confirm the bucket policy before blaming the package.
Passkeys pull in Spatie. The passkeys-migrations tag implies spatie/laravel-passkeys and its HasPasskeys contract. If you don't want passkey support, simply never enable enablePasskey — but you'll still need the trait/contract if the migrations were published, so publish that tag only when you intend to use it.
Not registered = no routes. If /profile 404s, the plugin isn't in the panel's ->plugin() array, or you're hitting the wrong panel. After any provider change: php artisan optimize:clear then php artisan filament:optimize-clear if the panel is cached.
Translations are reactive. Labels are translatable and the team-settings label resolves dynamically (per 0.0.15). Publish the lang files and override keys rather than hardcoding English in a custom page — otherwise your custom page will break locale switching while the rest of the panel follows along.
Team deletion should be gated. The package supports a gate check on team deletion (added in 0.0.7). Register a policy for your Team model and enforce ownership/role via the Role model — don't assume the default admin/editor split matches your product's permission model. Most teams end up extending Role on day two.
Ecosystem risk is real. 178 stars and zero dependents means a small blast radius: few Stack Overflow answers, few third-party tutorials, and no crowd to catch regressions for you. Read the source when behavior surprises you — the plugin class and page classes are the whole story. If you hit a wall, forking is a realistic option here rather than a last resort, which is a legitimate reason to keep your customizations confined to published files and plugin config.
How can I help you explore Laravel packages today?