Skip to content

Repository files navigation

JamesGifford Auth

Tests Code Style PHP Laravel License

Reusable Laravel authentication scaffolding: prefixed public identifiers and multi-account memberships with configurable roles and enforced invariants.

Overview

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.

Requirements

  • PHP 8.4+ (developed on PHP 8.5)
  • Laravel 13

Installation

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/auth

Setup

jamesgifford: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 Deprecated, no effect. Setup no longer edits tests/TestCase.php; the flag is still accepted so existing scripts keep working.
--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-migrations tag exists but is internal — it copies the package's frozen source timestamps verbatim, which can sort incorrectly against your app's migrations. jamesgifford:auth:install is the supported path: it publishes the migrations with fresh timestamps so they order correctly.

Upgrading to 1.3

After composer update, in development:

  1. Publish the new roles migration. Run php artisan jamesgifford:auth:install. It publishes only the package migrations your app doesn't have yet (here, *_insert_jamesgifford_auth_account_roles.php), runs migrate, and skips every step that's already done. Commit the new migration file, and your deploy's usual php artisan migrate --force runs it in production. Don't use vendor:publish for this: it would copy every package migration again.
  2. Check anything that inserts account roles itself. The migration only inserts roles whose key has no row yet and never changes existing rows, so roles you already seeded (including renamed ones) are left alone. The conflict to look for is code that inserts a configured role without checking whether it exists, such as AccountRole::create(['key' => 'owner', ...]), DB::table('account_roles')->insert(...), or AccountRole::factory()->create(['key' => 'admin']) in a seeder, migration, or test. Every migrated database now already has those rows, so that code hits the unique index on account_roles.key. Switch it to firstOrCreate/updateOrCreate, or delete it (AccountRoleSeeder is already idempotent). Tests that expect account_roles to start empty need updating for the same reason.
  3. Send a CSRF token to the switch route. The account routes now run in the web group, so POST /account/switch/{account} needs a CSRF token: @csrf in a Blade form, or the X-XSRF-TOKEN header that axios/Inertia send automatically from the XSRF-TOKEN cookie. If you call these routes from a stateless client instead, set http.routes.middleware (e.g. ['api', 'auth:sanctum']); see Account switching and HTTP.
  4. Optional: replace any user-model saving/updated hook that watches current_account_id with a listener for the new CurrentAccountChanged event. Existing hooks keep working, with one difference: switchToAccount() to the account that is already current no longer saves the user, so it no longer fires model events.
  5. Optional: remove protected $seed = true; from tests/TestCase.php if an earlier install added it and your own seeders don't need to run before every test. Test databases get roles from the migration now, and install no longer adds, checks, or removes that property. --skip-test-seeding has no effect any more, so you can drop it from scripts; it's still accepted.

Rolling the roles migration back deletes the configured roles that no membership references, including rows you seeded before the migration existed. Re-running migrate (or AccountRoleSeeder) restores them.

Quick start

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 account

Public IDs

A 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.

The HasPublicId trait

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 populate public_id — it reads unique IDs through a different code path (Builder::addUniqueIdsToUpsertValues()) that the trait doesn't hook. replicate() copies public_id onto the clone rather than clearing it, so saving a replicated model will violate the unique index unless you reassign it yourself.

The PublicId facade

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 migrations

Validation rule

ValidPublicId 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
]);

Format locking

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.

Commands

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.

Accounts and memberships

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 defined in config and inserted by a package migration. Accounts are soft-deletable so membership history survives a deletion.

The HasAccounts trait

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);

switchToAccount() updates the cached currentAccount relation in place, so $user->currentAccount returns the new account straight away, in the same request, with no refresh() needed. It also dispatches CurrentAccountChanged when the account actually changes.

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.

Registration auto-creates an account

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").

AccountService

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 (and the cached currentAccount relation on $user) 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, and clear it as the current account of every user it was current for.
restore(Account $account) Restore a soft-deleted account.
forceDelete(Account $account) Permanently delete the account, cascade its memberships, and clear it as anyone's current account.

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.

Roles

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. The owner role is required and protected. It cannot be deleted, because the account model depends on it.

The roles are inserted by a package migration (*_insert_jamesgifford_auth_account_roles), which install publishes alongside the table migrations and which runs after them. So migrate:fresh, a RefreshDatabase test database, and a first production deploy all have every role that account creation needs, with no seeder involved. The migration reads the same definitions as AccountRoleSeeder, config('jamesgifford.auth.roles'), and falls back to the config file if that config is empty because it was cached before the package was installed. It inserts only roles that have no row yet and never changes existing rows. Its down() removes the configured roles, except any a membership still references.

A role you add to config later is picked up by the next fresh migrate. For an existing database, run AccountRoleSeeder, which also pushes renamed or re-described roles onto rows that already exist (the migration never does that).

The single-owner invariant

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.

Events

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
CurrentAccountChanged switchToAccount, detachUser, delete, forceDelete, and the EnsureCurrentAccount middleware (see below)

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
});

Current account changes

CurrentAccountChanged is dispatched whenever a user's current account actually changes, so you can react to it (for example, to lock account-scoped state) without hooking the user model's save. It is never dispatched when the account is set to the one that is already current. It carries the user and both accounts as snapshots:

use Illuminate\Support\Facades\Event;
use JamesGifford\Auth\Events\CurrentAccountChanged;

Event::listen(function (CurrentAccountChanged $event) {
    $event->user->id;                     // UserTransfer
    $event->previousAccount?->publicId;   // ?AccountTransfer — null on a user's first switch
    $event->newAccount?->publicId;        // ?AccountTransfer — null when the current account was cleared
});

It fires when:

  • switchToAccount() moves the user to a different account, including the first switch at registration;
  • detachUser() removes the user from their current account (the new account is null);
  • delete() or forceDelete() removes an account, once for each user it was current for (new account null);
  • the EnsureCurrentAccount middleware clears or replaces a current account that was deleted or whose membership was lost. A replacement is reported as a single change from the old account to the new one. The previous account is still reported when it has been soft-deleted.

Like the other events it waits for the surrounding transaction to commit and never fires on a rollback. Writes that bypass the package don't fire it: a direct current_account_id update, or DevDataSeeder, which sets the dev cast's current accounts directly. delete() and forceDelete() update the affected rows in bulk, so user instances already in memory elsewhere keep the old value until they are reloaded. EnsureCurrentAccount corrects this on the user's next request.

AccountIntegrityService

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 issues

Account switching and HTTP

The 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.

The routes run in the web middleware group behind auth by default, so they get the session, cookies, and CSRF protection a session-authenticated app needs. The switch route is a POST, so send a CSRF token with it: @csrf in a Blade form, or the X-XSRF-TOKEN header that axios and Inertia send automatically. To serve the routes some other way, set http.routes.middleware in the published config, for example ['api', 'auth:sanctum'] for token-authenticated clients. Route-model binding for {account} is applied whatever that list contains. If your published config predates this key, the ['web', 'auth'] default applies.

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). It also updates the cached currentAccount relation and dispatches CurrentAccountChanged. 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. Whatever it assigns or clears is visible on $request->user()->currentAccount in the rest of the request, and dispatches CurrentAccountChanged.

Configuration

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-model prefixes map. The format settings are locked after setup; only prefix_max_length and prefixes can 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 the roles migration (and AccountRoleSeeder) insert into the database; add custom roles here.
  • accounts — account behavior, such as the default name template used when create() is called without a name.
  • id_offsets — optional auto-increment starting values for the users and accounts tables (see Development).
  • http — enabled, the account routes' middleware (routes.middleware, default ['web', 'auth']), and the EnsureCurrentAccount redirect targets.

Environment variables

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.

Development

Dev-data seeding

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
],

Seeding from your DatabaseSeeder

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 --seed

migrate: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:

  • AccountRoleSeeder reads config('jamesgifford.auth.roles') and runs unconditionally. The roles migration already inserts any missing roles on every migrate; the seeder also pushes later config edits (renames, new descriptions) onto rows that already exist.
  • DevDataSeeder reads config('jamesgifford.auth-dev') and self-guards: outside local/staging, and always in production, it logs a notice and returns without seeding or throwing.
  • ApplyIdOffsetsSeeder re-applies your configured ID offsets, which a --seed rebuild 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 the ALTER — 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.

Testing in your application

Registering a user, or any code path that calls AccountService::create() (e.g. CreateAccountOnRegistration), requires the owner role to exist in account_roles. The roles come from a package migration (see Roles), so a plain RefreshDatabase test suite already has them. RefreshDatabase runs your migrations, and no seeding is involved. Nothing needs adding to tests/TestCase.php, and the installer never edits it.

Before 1.3, install and setup added protected $seed = true; to tests/TestCase.php to get roles into test databases. They no longer add it, check for it, or remove one that's already there. That property makes RefreshDatabase run your whole DatabaseSeeder before every test, so delete it unless your own seeders need to run per test.

If the roles migration isn't in your database/migrations yet (an app installed before 1.3 that hasn't re-run install), seeding just the roles also works:

$this->seed(\JamesGifford\Auth\Database\Seeders\AccountRoleSeeder::class);

ID offsets

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.

AI tooling

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).

Uninstall

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.

Commands

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.

Install flags

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 Deprecated, no effect. Install no longer edits tests/TestCase.php; the flag is still accepted so existing scripts keep working.
--skip-id-offsets Don't apply ID offsets here (the setup command passes this so it can apply them itself, after dev-data seeding).

Testing

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 SQLite

composer 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.

License

Released under the MIT License.

Author

Built and maintained by James Gifford.

About

Reusable authentication scaffolding for Laravel: prefixed public IDs, accounts and memberships with roles, and automatic account creation on registration.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages