Skip to content

Administration

The administration panel ships as laraspring/admin, a Composer package your application installs. It answers the three questions every SaaS asks itself within a week of launching: who are my users, why can this one not sign in, and what is the customer on the phone actually looking at.

It is a panel inside your product rather than a second application: ordinary Inertia pages, in the same layout as everything else, behind one ability.

What you get out of the box

FeatureWhere
One ability that opens the panelLaraspring\Admin\Support\Admin::ACCESS
The first administratorphp artisan laraspring:admin you@example.com
Users, paged and searchable in the databaseadmin.users
One user, with everything your application knowsadmin.users.show
Suspending an account, with a reason and an optional endadmin.users.ban
Signing in as a user, safelyadmin.impersonate.start
An audit trail of every action takenadmin_actions
Counters that never call a payment provideradmin.overview
Sections, counters and badges your application addsAdminResourceRegistry

Making the first administrator

There is no way to promote somebody from inside the panel, because a panel that could do that would need somebody already in it. The first one is made from a shell on the server:

bash
php artisan laraspring:admin ada@example.com
php artisan laraspring:admin ada@example.com --revoke

That flips is_admin on the user's row. The column belongs to your application, not to the package, and the reference edition ships the migration that adds it. is_admin is deliberately absent from the model's $fillable: a flag a form can set is a flag a registration form can grant.

Deciding who is an administrator yourself

Nothing in the package reads the column. Everything asks the gate, so one definition in your application moves every check at once, the middleware on the panel, the entry in the user menu, and the rule that says an administrator may never be impersonated:

php
use Illuminate\Support\Facades\Gate;
use Laraspring\Admin\Support\Admin;

Gate::define(Admin::ACCESS, function (User $user): bool {
    return $user->hasRole('support') || str_ends_with($user->email, '@yourcompany.com');
});

Your definition wins over the package's, because application providers boot after discovered ones. It is the same arrangement laraspring/billing uses for manageBilling.

An installation whose users table has no is_admin column at all answers "nobody is an administrator" rather than throwing, so the window between installing the package and running your migrations is a closed door rather than a broken application.

What the panel knows about your product

Nothing, deliberately, and that is the design rather than a limitation. A panel is about the whole application by definition, and laraspring/admin depends on none of it: no laraspring/auth, no laraspring/organizations, no laraspring/billing. Naming them would put three optional packages in the panel's composer.json and make an installation without them fail to boot instead of showing a shorter screen.

So the panel holds a registry and your application fills it, the same shape laraspring/mail, laraspring/storage and laraspring/i18n use. Four kinds of thing, because the panel has four holes in it:

php
use Laraspring\Admin\Panel\{AdminResourceRegistry, AdminSection, AdminStat, AdminUserBadge, AdminUserPanel};

public static function register(AdminResourceRegistry $registry): void
{
    // A screen of your own in the panel's navigation. Guard its routes with
    // the panel's middleware alias, `laraspring.admin`.
    $registry->section(new AdminSection(
        key: 'invoices',
        titleKey: 'app.admin.nav.invoices',
        routeName: 'admin.invoices',
        order: 40,
    ));

    // A counter on the overview. Local queries only.
    $registry->stat(new AdminStat(
        key: 'invoices',
        labelKey: 'app.admin.stats.invoices',
        value: fn (): int => Invoice::query()->count(),
    ));

    // A label next to a person's name.
    $registry->userBadge(new AdminUserBadge(
        key: 'two_factor',
        labelKey: 'app.admin.badges.two_factor',
        applies: fn (Model $user): bool => TwoFactor::isRequiredFor($user),
        tone: 'positive',
    ));

    // A block of facts on their detail screen.
    $registry->userPanel(new AdminUserPanel(
        key: 'invoices',
        titleKey: 'app.admin.panels.invoices',
        rows: fn (Model $user): array => [/* label, value, url */],
    ));
}

The reference edition does exactly this in app/Admin/LaraspringAdminPanel.php, called from AppServiceProvider::boot(). That is where the organizations section, the per-plan counters and the two-factor badge come from: every one of them is a fact belonging to a package the panel may not name.

A section whose route is not registered is filtered out when the navigation is drawn, so a screen behind a feature flag disappears rather than leading to a 404.

The overview never calls Stripe

Every counter is a query against your own database, and that is a rule rather than an implementation detail. The overview is the screen an administrator opens when something is wrong, and a counter that waits on a payment provider would make it the screen that is down whenever the provider is. A counter that throws renders as a dash; the rest of the page still draws.

The reference edition counts active subscriptions per plan by reading the local subscriptions table by price identifier, not by asking the provider.

How long the audit trail is kept

admin_actions is written to on every ban, every lift and both ends of every impersonation, and nothing in the package ever edits a row: a trail an application can rewrite proves nothing. It does grow forever, though, so age is the one thing allowed to remove a line.

dotenv
LARASPRING_ADMIN_AUDIT_RETENTION=365
php
// routes/console.php
Schedule::command('laraspring:prune-audit')->daily();

There is deliberately no default. Without a configured period the command refuses rather than guessing, because guessing wrong here deletes the answer to "who signed in as that customer, and when". Check what your own retention policy and your jurisdiction require before picking a number.

--days overrides the configured period for one run, and --pretend reports what would go without deleting anything:

bash
php artisan laraspring:prune-audit --days=365 --pretend

Deletion happens in chunks, because the first run on an installation that has never pruned is the one big enough to hold a lock worth worrying about.

What is next

Laraspring is a commercial starter kit. Buying it gets you the source.