Reusable Laravel authentication scaffolding: prefixed public identifiers and multi-account memberships with configurable roles and enforced invariants.
Most applications eventually need the same two pieces of plumbing: stable public-facing identifiers that aren't auto-increment integers, and a way to group users into accounts with roles. This package provides both as reusable, tested scaffolding so each application doesn't reimplement them, and ships a single setup command that wires the whole thing into a fresh app.
The public ID subsystem generates prefixed, URL-safe identifiers like account_5n0p4kn48da58kdnzpkw. The format — separator, body length, alphabet, and optional checksum — is configurable, but once an application is set up the format is locked: a fingerprint of the format is written to a lock file, and any subsequent drift from it is detected at boot and rejected. Public IDs end up in URLs, external systems, and customer bookmarks; silently changing how they're generated would invalidate everything already issued. Locking the format makes that mistake impossible to make by accident.
The accounts subsystem models accounts, their members (through an explicit pivot carrying a role), and a strict single-owner invariant: every account has exactly one owner at all times, and the only way to change it is an atomic ownership transfer. All mutating operations run inside transactions and dispatch events only after commit. Those events carry immutable snapshots of the data as it was at the time of the operation, not live Eloquent models — so listeners and queued jobs see a stable, serializable record rather than a mutable reference that may have changed by the time they run. Registration auto-creates a personal account, so every user has somewhere to land without bespoke wiring.
- PHP 8.4+ (developed on PHP 8.5)
- Laravel 13
This package is distributed via its Git repository rather than Packagist. Add the repository to your application's composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/jamesgifford/auth.git"
}
]
}Then require the package:
composer require jamesgifford/authjamesgifford:auth:setup is the primary entry point. It sequences the whole setup in the correct order: migrate (or migrate:fresh), install (lock the public_id format, publish and run migrations, seed roles, modify the User model), optionally seed local dev data, and apply ID offsets.
# Interactive local setup (pauses to explain the irreversible public_id lock)
php artisan jamesgifford:auth:setup
# Local: start from a clean database and seed the dev cast
php artisan jamesgifford:auth:setup --fresh --with-dev-data
# Non-interactive (CI / production): skip the educational pause, propagate --force
php artisan jamesgifford:auth:setup --force| Flag | Effect |
|---|---|
--fresh |
Reset the database with migrate:fresh first. Development only — the command refuses in production. |
--with-dev-data |
Also seed the deterministic local dev cast, and wire DevDataSeeder into DatabaseSeeder. The seeder refuses in production even with this flag. |
--skip-seeder-wiring |
Don't touch database/seeders/DatabaseSeeder.php; print the calls to add instead. |
--skip-test-seeding |
Don't touch tests/TestCase.php; print the property to add instead. |
--force |
Run non-interactively: skip the educational pause and propagate --force to the migrate step. |
The interactive flow pauses before the irreversible public_id lock to surface the format that's about to be locked. In production you run it non-interactively with --force; --fresh and --with-dev-data are refused there regardless.
If you prefer to run the steps yourself, jamesgifford:auth:install performs just the install stage. See Commands for the full list.
Note on migrations: a
vendor:publish --tag=jamesgifford-auth-migrationstag exists but is internal — it copies the package's frozen source timestamps verbatim, which can sort incorrectly against your app's migrations.jamesgifford:auth:installis the supported path: it publishes the migrations with fresh timestamps so they order correctly.
Registration auto-creates a personal account the user owns, so a freshly registered user is immediately a member of one account. From there, the typical interaction looks like this:
use JamesGifford\Auth\Accounts\Services\AccountService;
$accounts = app(AccountService::class);
// Create another account; the given user becomes its owner
$account = $accounts->create($user, 'Acme Inc');
// Add a team member with the 'admin' role
$accounts->attachUser($account, $teammate, 'admin');
// Query membership directly on the User model (via the HasAccounts trait)
$user->isOwnerOf($account); // true
$user->accounts; // every account the user belongs to
$user->currentAccount; // the user's active account
$user->switchToAccount($account); // set the user's active accountA public ID is composed of a prefix, a separator, a random body, and an optional checksum:
account_5n0p4kn48da58kdnzpkw
└──┬──┘ └────────┬─────────┘
prefix body + checksum
The default prefixes are user (for App\Models\User) and account (for the package's Account model). The format is configurable (see Configuration) — body length, alphabet, separator, and whether a checksum is appended. The checksum, when enabled, lets the validator detect transcription typos rather than just malformed input.
Apply the trait to any Eloquent model and declare its prefix:
use JamesGifford\Auth\PublicId\Concerns\HasPublicId;
class Invoice extends Model
{
use HasPublicId;
public function publicIdPrefix(): string
{
return 'inv'; // must be <= prefix_max_length (default 7)
}
}The prefix may instead be registered in the public_id.prefixes config map. Resolution order is: the model's publicIdPrefix() override, then the config map, otherwise an UnregisteredModelException.
The trait generates public_id off Eloquent's setUniqueIds() unique-id hook rather than a creating event, and overrides route-model binding so a public ID resolves the model out of the box:
// GET /invoices/inv_5n0p4kn48da58kdnzpkw resolves by public_id
Route::get('/invoices/{invoice}', fn (Invoice $invoice) => $invoice);
// Query scopes
Invoice::wherePublicId('inv_5n0p4kn48da58kdnzpkw')->first();
Invoice::wherePublicIdIn([$idA, $idB])->get();Model::performInsert() calls setUniqueIds() directly, before it fires the creating event, so public_id is populated even when model events are suppressed — inside Model::withoutEvents(), via saveQuietly(), or under a DatabaseSeeder using Illuminate\Database\Console\Seeds\WithoutModelEvents. A model that also uses HasUuids or HasUlids still gets both keys populated: HasPublicId calls parent::setUniqueIds() first, then fills public_id if it's still empty.
The model's table needs a public_id column sized with PublicId::maxLength():
$table->string('public_id', PublicId::maxLength())->unique();Limitations:
Model::upsert()does not populatepublic_id— it reads unique IDs through a different code path (Builder::addUniqueIdsToUpsertValues()) that the trait doesn't hook.replicate()copiespublic_idonto the clone rather than clearing it, so saving a replicated model will violate the unique index unless you reassign it yourself.
A static entry point for generation and validation:
use JamesGifford\Auth\PublicId\PublicId;
PublicId::generate('user'); // 'user_…'
PublicId::isValid($id); // true / false
PublicId::isValid($id, 'user'); // also require the 'user' prefix
PublicId::validate($id); // ValidationResult
PublicId::parse($id); // ValidationResult
PublicId::prefixOf($id); // 'user', or null if invalid
PublicId::maxLength(); // column size for migrationsValidPublicId plugs into Laravel's validator:
use JamesGifford\Auth\PublicId\Rules\ValidPublicId;
$request->validate([
'user_id' => ['required', new ValidPublicId('user')],
'account_id' => ['nullable', ValidPublicId::withPrefix('account')],
'reference' => ['required', new ValidPublicId()], // any prefix
]);The format-defining settings are locked the first time you run jamesgifford:public-id:setup (the installer does this for you). Setup writes a fingerprint of the format to a lock file (default config/jamesgifford/auth.lock.json). On every subsequent boot, the package recomputes that fingerprint from the current config and compares it to the lock; if they diverge, boot fails with a clear error rather than quietly issuing IDs in an incompatible format. Only prefix_max_length and the per-model prefixes map are safe to change after lock — and a prefix only for a model that has no IDs yet. Changing a locked format requires an explicit, deliberately destructive reset.
| Command | Description |
|---|---|
jamesgifford:public-id:setup |
Lock the public_id configuration for this application. |
jamesgifford:public-id:status |
Display the current public_id configuration status. |
jamesgifford:public-id:check |
Verify public_id prefix registry integrity and detect config issues. |
jamesgifford:public-id:reset |
Clear the public_id configuration lock. Destructive: invalidates all previously generated IDs. Requires --i-understand-this-breaks-existing-ids. |
An account has an owner and zero or more members. Membership is stored in an explicit pivot (account_user) that records the member's role and when they joined. Roles are reference data seeded from config. Accounts are soft-deletable so membership history survives a deletion.
Applied to your User model, it adds relationships, role checks, and account switching:
// Relationships
$user->accounts; // BelongsToMany — accounts the user is a member of
$user->memberships; // HasMany — the pivot rows directly
$user->currentAccount; // BelongsTo — the user's active account (nullable)
$user->ownedAccounts; // HasMany — accounts the user owns
// Membership & role checks (scoped to a given account)
$user->belongsToAccount($account); // bool
$user->membershipIn($account); // AccountUser|null
$user->roleIn($account); // AccountRole|null
$user->hasRole($account, 'admin'); // bool — exact match
$user->hasAnyRole($account, ['admin', 'member']);
$user->isOwnerOf($account); // bool
$user->isAdminOf($account); // bool — true for owners as well as admins
$user->hasAnyAccount(); // bool
$user->isFloating(); // bool — authenticated but no current account
// Switching the active account (throws NotAMemberException if not a member)
$user->switchToAccount($account);isAdminOf() deliberately returns true for owners as well as admins, reflecting the common authorization rule that owner privileges include admin privileges. hasRole(), by contrast, is an exact match — an owner does not "have" the admin role.
A listener (CreateAccountOnRegistration) on Laravel's Illuminate\Auth\Events\Registered event creates a personal account the new user owns. It's idempotent — it skips users who already belong to an account — so you don't write account-creation code for normal registration. The default name comes from accounts.default_name_template ("{name}'s Account").
All account mutations go through the service. Every method runs in a database transaction and dispatches its event via afterCommit, so listeners never fire for work that was rolled back. Don't touch the account_user pivot directly.
| Method | Purpose |
|---|---|
create(Model $owner, ?string $name = null) |
Create an account and seed the owner's membership. Falls back to a configurable default name. |
attachUser(Account $account, Model $user, string $roleKey) |
Add a member with the given role. |
detachUser(Account $account, Model $user) |
Remove a member; clears their current_account_id if it pointed here. |
changeRole(Account $account, Model $user, string $newRoleKey) |
Change a member's role. |
transferOwnership(Account $account, Model $newOwner, string $previousOwnerNewRoleKey = SystemRole::ADMIN) |
Atomically hand ownership to another existing member. |
delete(Account $account) |
Soft-delete the account. |
restore(Account $account) |
Restore a soft-deleted account. |
forceDelete(Account $account) |
Permanently delete the account and cascade its memberships. |
attachUser and changeRole refuse to assign the owner role — ownership is managed only through create and transferOwnership. Likewise detachUser refuses to remove the owner. These guards keep the single-owner invariant intact.
Four roles ship by default and are referenced through SystemRole constants:
use JamesGifford\Auth\SystemRole;
SystemRole::OWNER; // 'owner'
SystemRole::ADMIN; // 'admin'
SystemRole::MEMBER; // 'member'
SystemRole::VIEWER; // 'viewer'Roles are configurable — add your own in config/jamesgifford/auth.php and re-run the seeder. The owner role is required and protected: it cannot be deleted, because the account model depends on it.
Every account has exactly one owner at all times. The owner is both a column on the account (owner_id) and a membership row with the owner role, and the two are kept in sync. Ownership cannot be reassigned by editing a role or detaching a user — the only path is transferOwnership, which demotes the previous owner (to admin by default), promotes the new owner, and updates the account in a single transaction. No intermediate "two owners" or "zero owners" state is ever observable.
Each operation dispatches an event after its transaction commits:
| Event | Dispatched by |
|---|---|
AccountCreated |
create |
UserAttachedToAccount |
attachUser |
UserDetachedFromAccount |
detachUser |
AccountRoleChanged |
changeRole |
AccountOwnershipTransferred |
transferOwnership |
AccountDeleted |
delete |
AccountRestored |
restore |
AccountForceDeleted |
forceDelete |
Events carry immutable snapshots (Transfer objects), not live models:
use Illuminate\Support\Facades\Event;
use JamesGifford\Auth\Events\AccountCreated;
Event::listen(function (AccountCreated $event) {
$event->account->publicId; // AccountTransfer — a snapshot
$event->owner->email; // UserTransfer
});A read-only scanner that detects accounts violating the owner invariant — no owner membership, multiple owner memberships, or an owner_id that disagrees with the owner-role member. Useful for auditing data that may have been modified outside the service layer.
use JamesGifford\Auth\Accounts\Services\AccountIntegrityService;
$issues = app(AccountIntegrityService::class)->scan(); // Collection of issuesThe package ships a frontend-agnostic HTTP layer: the controllers only redirect or return JSON — never a view — so it works identically on Livewire, Inertia, Blade, or API stacks. The routes and the middleware alias are registered only when http.enabled is true (run install --without-http to disable). {account} is resolved by public_id.
| Method & path | Route name | Behavior |
|---|---|---|
POST /account/switch/{account} |
jamesgifford-auth.account.switch |
Switch the current account; redirect (web) or JSON (API). |
GET /account/list |
jamesgifford-auth.account.list |
The user's accounts as JSON (public_id, name, is_current). |
The backend primitive is $user->switchToAccount($account), which validates membership, then sets and persists current_account_id (throwing NotAMemberException if the user isn't a member). The HTTP switch route is a thin wrapper over it.
When HTTP is enabled, the package registers an explicit route binder for the {account} parameter, bound to your configured models.account class and resolving by public_id. Note this binder applies by parameter name application-wide: your own routes using {account} will resolve through it too (set http.enabled to false if you need different {account} semantics).
Apply the auth.current-account middleware alias (the EnsureCurrentAccount middleware) to routes that require an active account. Its redirect destinations are config route names: http.middleware.redirect_floating_to (no current account) and redirect_missing_to (current account gone). When either is null, the middleware auto-assigns a sensible account and continues instead of redirecting.
Configuration lives in config/jamesgifford/auth.php after publishing. The main areas are:
public_id—prefix_max_length, separator, body length and alphabet, checksum settings, the lock file path, and the per-modelprefixesmap. The format settings are locked after setup; onlyprefix_max_lengthandprefixescan change afterward.models— the User, Account, AccountRole, and AccountUser classes the package resolves, so you can point them at your own subclasses.roles— the roles seeded into the database; add custom roles here.accounts— account behavior, such as the default name template used whencreate()is called without a name.id_offsets— optional auto-increment starting values for the users and accounts tables (see Development).http—enabledplus theEnsureCurrentAccountredirect targets.
All package env vars use the JAMESGIFFORD_AUTH_ prefix and are read only in config files (don't call env() in application code):
| Variable | Used by |
|---|---|
JAMESGIFFORD_AUTH_DEV_USERS_PASSWORD |
Shared password for seeded dev users (config/jamesgifford/auth-dev.php, key users_password); hashed at seed time. |
JAMESGIFFORD_AUTH_USERS_ID_OFFSET |
Auto-increment start for the users table. |
JAMESGIFFORD_AUTH_ACCOUNTS_ID_OFFSET |
Auto-increment start for the accounts table. |
jamesgifford:auth:seed-dev-data seeds a deterministic local cast (owners, admins, members, a multi-account user, and a floating user) defined in config/jamesgifford/auth-dev.php. It fails closed: it refuses in production and outside the configured environments (default local, staging). The shared password is sourced from JAMESGIFFORD_AUTH_DEV_USERS_PASSWORD and hashed at seed time — no credential is committed. When the variable is unset the command warns after seeding that the default password (password) was used, and a leftover pre-1.2.2 password config key is reported as ignored (the key is now users_password). The cast ships pre-populated, so a fresh install is immediately seedable (or via setup --with-dev-data).
The config declares accounts explicitly and each user's memberships from that user's own perspective, so multi-account membership, roles, and the active account are all expressed in one place per entity:
'accounts' => [
['name' => 'Acme Inc', 'owner' => 'owner@dev.test'],
['name' => 'Beta LLC', 'owner' => 'multi@dev.test'],
],
'users' => [
['name' => 'Owner', 'email' => 'owner@dev.test'], // owns Acme Inc
['name' => 'Admin', 'email' => 'admin@dev.test', 'memberships' => [
['account' => 'Acme Inc', 'role' => 'admin'],
]],
// Owns Beta LLC and is a member of Acme Inc; active account set to Beta LLC.
['name' => 'Multi', 'email' => 'multi@dev.test', 'memberships' => [
['account' => 'Acme Inc', 'role' => 'member'],
], 'current_account' => 'Beta LLC'],
['name' => 'Floating', 'email' => 'floating@dev.test'], // owns/belongs to nothing
],The setup commands wire this up for you — there is nothing to paste in. jamesgifford:auth:install adds the two always-on seeders to database/seeders/DatabaseSeeder.php, and jamesgifford:auth:setup --with-dev-data adds the dev fixtures, leaving:
public function run(): void
{
// Auth: required account roles — ALL environments.
$this->call(\JamesGifford\Auth\Database\Seeders\AccountRoleSeeder::class);
// Auth: development fixtures — the seeder refuses outside
// local/staging and always in production.
$this->call(\JamesGifford\Auth\Database\DevDataSeeder::class);
// Auth: reserve low IDs for the fixtures above. No-op when no
// offsets are configured, and on SQLite.
$this->call(\JamesGifford\Auth\Database\Seeders\ApplyIdOffsetsSeeder::class);
// ...your own seeders, untouched
}So rebuilding the database takes one command:
php artisan migrate:refresh --seed # or: migrate:fresh --seedmigrate:refresh on its own re-runs migrations without seeding — the --seed flag is Laravel's, and this wiring is what makes it repopulate the package's data.
Each seeder is independently safe:
AccountRoleSeederreadsconfig('jamesgifford.auth.roles')and runs unconditionally — roles are required data in every environment.DevDataSeederreadsconfig('jamesgifford.auth-dev')and self-guards: outsidelocal/staging, and always in production, it logs a notice and returns without seeding or throwing.ApplyIdOffsetsSeederre-applies your configured ID offsets, which a--seedrebuild resets. It is a no-op when no offsets are configured and on SQLite. Offsets are a convenience rather than a correctness requirement, so any failure — a malformed offset, or a driver refusing theALTER— is logged and skipped rather than allowed to abort your seeding run. It runs last so the offsets land above the fixtures.
Your own seeders are never touched. The package's edits are made through PHP's AST with a format-preserving printer, so unrelated content — your seeders, comments, docblocks, and formatting — is preserved byte for byte, and re-running setup never duplicates a call. jamesgifford:auth:uninstall removes only the package's $this->call(...) lines and leaves yours in place.
Pass --skip-seeder-wiring to install or setup to manage the file yourself; the commands then print the lines to add. If your application's root seeder is not at database/seeders/DatabaseSeeder.php, or that file cannot be safely parsed, the commands print the same instructions rather than editing anything.
AccountRoleSeeder is DDL-independent of your migrations: RefreshDatabase (or migrate:fresh) rebuilds the schema fresh for every test, but it does not run seeders unless the test opts in. Registering a user, or any code path that calls AccountService::create() (e.g. CreateAccountOnRegistration), requires the owner role to exist in account_roles — so a bare RefreshDatabase test suite would 500 on the first registration with InvalidRoleException: ... has no matching row in the account_roles table, even though config('jamesgifford.auth.roles') is perfectly valid.
install and setup handle this for you: they add protected $seed = true; to tests/TestCase.php (this also covers a Pest suite, since Pest's tests/Pest.php still uses(Tests\TestCase::class)), so RefreshDatabase seeds DatabaseSeeder — and therefore AccountRoleSeeder, which the installer already wired in — on every test. Nothing to add yourself. It's skipped, never overwritten, when the class already seeds some other way (an existing $seed property of any value, a #[Seed] attribute, or an overridden seeder() method) — an explicit choice, including an explicit opt-out, is always respected.
Pass --skip-test-seeding to manage this yourself; the commands then print the property to add. If your test suite has no tests/TestCase.php, or that file cannot be safely parsed, they print the same instructions rather than editing anything — for example, in a Pest suite:
// tests/Pest.php
uses(RefreshDatabase::class)->beforeEach(fn () => $this->seed())->in('Feature');If you'd rather not seed the whole DatabaseSeeder per test, seed just the roles:
$this->seed(\JamesGifford\Auth\Database\Seeders\AccountRoleSeeder::class);jamesgifford:auth:apply-id-offsets sets the auto-increment starting values for the users and accounts tables (from the id_offsets config / env vars above), so real records begin above a chosen number and low IDs stay reserved for deterministic dev fixtures. Run it after migrating and after any seeding. Supported on MySQL/MariaDB and PostgreSQL; a no-op on SQLite. setup runs this as its final step.
The package ships a Laravel Boost skill (resources/boost/skills/jamesgifford-auth/) that teaches an AI assistant the package's public API and guardrails — public IDs, the account/role model, switching, and the setup commands. In a consuming app that uses Boost, install it with php artisan boost:install (or boost:update to refresh).
jamesgifford:auth:uninstall removes the package's footprint: it drops the package tables, reverts the User model modifications, removes the package's $this->call(...) lines from your DatabaseSeeder (leaving your own seeders in place), removes the package config, and clears the public_id lock. It is destructive (drops tables and deletes data) and prompts before proceeding, listing everything it will touch first.
php artisan jamesgifford:auth:uninstall| Flag | Effect |
|---|---|
--keep-config |
Keep the published config file instead of deleting it. |
--remove-published-models |
Also delete the published App\Models subclasses (interactive runs prompt instead). |
--force |
Skip the confirmation prompt (non-interactive use). |
--force-production |
Permit the uninstall to run in a production environment. |
| Command | Description |
|---|---|
jamesgifford:auth:setup |
Primary entry point: migrate, install, optionally seed dev data, apply ID offsets. |
jamesgifford:auth:install |
Install/configure the package (lock public_id, migrate, seed roles, modify User model). |
jamesgifford:auth:uninstall |
Remove the package's footprint. Destructive. |
jamesgifford:auth:seed-dev-data |
Seed the deterministic local dev cast (local/staging only). |
jamesgifford:auth:apply-id-offsets |
Apply auto-increment ID offsets to the users and accounts tables. |
jamesgifford:auth:publish-models |
Publish editable App\Models subclasses (Account, AccountUser, AccountRole) and register them in the models config. |
jamesgifford:public-id:setup |
Lock the public_id configuration. |
jamesgifford:public-id:status |
Display the current public_id configuration status. |
jamesgifford:public-id:check |
Verify prefix registry integrity and detect config issues. |
jamesgifford:public-id:reset |
Clear the public_id lock. Destructive; requires --i-understand-this-breaks-existing-ids. |
jamesgifford:auth:install accepts granular flags for non-standard flows:
| Flag | Effect |
|---|---|
--force |
Run non-interactively (skip all prompts). |
--fresh |
Tear down and cleanly redo the package setup. Development only — refuses if package data exists. |
--verify |
Only run the verification step; change nothing. |
--without-http |
Disable the HTTP plumbing (http.enabled = false in the published config). |
--publish-models |
Publish the editable App\Models subclasses without prompting. |
--skip-public-id / --skip-migrations / --skip-roles / --skip-user-model |
Skip individual install steps (--no-modify-user is an alias for --skip-user-model). |
--skip-seeder-wiring |
Don't touch database/seeders/DatabaseSeeder.php; print the calls to add instead. |
--skip-test-seeding |
Don't touch tests/TestCase.php; print the property to add instead. |
--skip-id-offsets |
Don't apply ID offsets here (the setup command passes this so it can apply them itself, after dev-data seeding). |
The package ships with a comprehensive test suite covering both subsystems, the service layer's transaction and event behavior, the invariant enforcement, the HTTP layer, and the installer.
The suite runs against MySQL by default — the package's actual target — so driver-real behavior (AUTO_INCREMENT offsets, DDL against populated tables, constraint names) is genuinely exercised, not simulated. It expects a local MySQL with a jamesgifford_auth_test database reachable via the settings in phpunit.xml's <php> block (override any of them with environment variables; CI does exactly that to point at a service container).
# Pint + PHPStan + the fast SQLite suite — the day-to-day gate
composer check
# Pint + PHPStan + the full MySQL suite — before a release, or when a
# change touches schema, DDL, or driver-specific behavior
composer check:full
# The suites on their own
composer test # MySQL (the integrity gate; slower — real DDL per test)
composer test:sqlite # fast in-memory SQLitecomposer check runs against SQLite because it is the loop you run dozens of times a day — seconds rather than minutes. It does not replace the MySQL run: CI always executes the full MySQL suite (vendor/bin/phpunit) on every push, so driver-real behavior is still gated before anything merges.
Driver-specific tests guard themselves: sqlite-only assertions (the offset no-op messaging) skip on MySQL and vice versa, so both commands run green with a couple of expected skips.
Released under the MIT License.
Built and maintained by James Gifford.