A standard suite of essentials for every Laravel app you build.
Concierge adds a standard suite of essentials and plumbing beyond Laravel's starter kits, so every app you build has a consistent foundation between laravel new and the product you're building.
Turn on local login, and your login page offers a one-click sign-in for local development:
// your login page's props
'localLogin' => app(\Tey\Concierge\LocalAuth\LocalLogin::class)->offer(),
With LocalLoginButton on that page, it shows Sign in as dev@example.test. One click creates the account if needed and signs you in. Outside the local environment the offer is null, the button renders nothing and the route answers 404.
[!NOTE]
Concierge is pre-1.0. Minor releases may change the API until 1.0.
Membership: invitations, memberships, ownership and role grants scoped to a workspace
Tenant invitations: a platform owner invites an email, and accepting gives that person their own new workspace
Local login: one-click sign-in as a configured account, in the local environment only
Vue pages: optional Vue 3 pages for members and invitations, and the local-login button
Composer also installs tey/mod, which concierge uses to write files into your app. Your app needs no setup for it and no config/mod.php: it gains the mod:* Artisan commands, and php artisan optimize caches mod's discovery.
The service provider is registered through package discovery. Publish the configuration to choose features and models:
The switches are read while the package provider registers, so set them in config/concierge.php, not at runtime. See Installation for the dependencies Composer brings in.
Quick Start
Membership is on by default, and its migration reads your user and workspace models, so set those up before you migrate:
Implement Tey\Concierge\Contracts\Member on your user model (with Spatie's HasRoles) and Tey\Concierge\Contracts\Workspace on your workspace model. Both tables need incrementing integer keys.
use App\Models\User;
use App\Models\Workspace;
use Tey\Concierge\Actions\EstablishWorkspace;
$owner = User::where('email', 'owner@example.com')->firstOrFail();
$workspace = Workspace::create(['name' => 'Acme']);
EstablishWorkspace::run($workspace, $owner);
$owner now holds the owner role in that workspace and can invite others. Membership has the contract methods and the rest of the setup.
An app that only wants local login sets 'features' => ['membership' => false] first; then migrate needs no workspace or permission setup.
App-wide mode is for the app with one organisation: invitations and roles apply to the whole app, there is no workspace model or table, and Spatie teams stay off. Set 'mode' => 'app', leave models.workspace null, migrate and sync the roles as above, then make the first owner with EstablishOwner::run($owner). The actions are InviteUser, ChangeUserRoles and RemoveUser; AcceptInvitation, ResendInvitation, RevokeInvitation and the concierge.member middleware work in both modes. Owner is the top role and several owners are allowed; granting it is how ownership passes on, and the last owner can never be removed or demoted. Your app's own "delete account" keeps working: deleting a user model removes their concierge row first, and the last owner is refused with a validation message on the password field. The Users pages and the upgrade to workspaces follow in 0.8 (#19).
ResendInvitation::run($actor, $invitation) and RevokeInvitation::run($actor, $invitation) use the AccountPolicy for app-wide invitations. Resending requires authority to grant every invitation role, rotates the token, renews expiry and honours the resend cooldown; revoking requires management authority and is idempotent. Accepted invitations cannot be resent or revoked. An elapsed invitation still marked pending can be resent; one already marked expired cannot. The actions dispatch InvitationResent and InvitationRevoked with a null workspaceId in app-wide mode.
Production
An invite-only app needs an owner before anyone can invite others. Set CONCIERGE_OWNERS to a comma-separated list of emails, read into concierge.owners with whitespace trimmed and blanks dropped:
In workspace mode (Spatie teams on), configure one owner and replace the owners line with php artisan concierge:owners --workspace=1 --no-interaction, using the key of an existing workspace. Your app creates the workspace; the command never does. A missing or unknown --workspace, several configured owners for one workspace, or an existing different owner exits non-zero without changing ownership. Starter kit teams are read-only and cannot be established by this command.
The command creates missing role definitions and looks up emails case-insensitively. For a new user it uses the email as the initial name, hashes a random password nobody knows, grants ownership and sends a set-password link through Laravel's default password broker. Configure that broker's user provider, mail delivery, APP_URL and your app's password-reset route before deploying. Email verification stays with your app's normal flow. Existing users keep their name, email, password, verification state and other role grants. Removing an email from the setting never removes a user or their ownership. EstablishOwner and EstablishWorkspace also preserve existing role grants when establishing ownership.
Only a user created by this invocation is emailed. Later deploys never resend the link, even if it expired or the owner has not chosen a password yet. Use your app's forgot-password flow to request another link. A broker refusal rolls back that owner's creation and role changes and exits non-zero; earlier owners successfully processed in the same invocation remain.
The command never prompts. --dry-run previews without writing any rows or sending links. --json returns an array with exactly email, outcome and boolean linkSent per email. Outcomes are created, role_added or already_owner; dry-run uses would_create, would_add_role or already_owner, always with linkSent: false. Errors return an error object and a non-zero exit code. An empty list (with a valid --workspace in workspace mode) or already-established owners exits 0. concierge.owners and tenant_invitations.platform_owners are separate settings: owning an app or workspace does not authorize inviting tenants.
Command reference
Run these as php artisan concierge:<command>. They are registered when concierge.features.membership is enabled. Every command runs without prompts, including with Laravel's --no-interaction (-n). Missing input or a refused operation exits non-zero and names the flag, configuration setting or command that fixes it. --json emits one JSON value without progress output; --dry-run makes no writes or deliveries. Laravel's standard options, including --help, --quiet and --no-ansi, also apply.
concierge:sync-roles
Creates missing global Spatie roles from concierge.roles, under concierge.guard (or the default auth guard). It never removes roles or changes assignments. Works in app-wide mode, workspace mode and the kit engine.
Option
Behaviour
--dry-run
Preview missing roles without writing or clearing the permission cache.
Establishes the emails in concierge.owners. See Production for broker setup and deploy behaviour.
Option
Behaviour
--workspace=<key>
Existing workspace key; required in workspace mode, unused in app-wide mode.
--dry-run
Preview without writing rows or sending password links.
--json
Array of {email, outcome, linkSent}; outcomes and errors are described above.
concierge:invite
Invites an email into the app with roles, as an existing user. App-wide mode only. The invitation is delivered as it is from the Users page and the roles are granted on acceptance; every rule InviteUser applies holds unchanged (rank, not yourself, the last owner, the host AccountGuard).
Option
Behaviour
--role=*
Repeatable; at least one required. A configured role (concierge.roles), granted on acceptance.
--as=<email>
Required; an existing user the invitation runs as.
--dry-run
Every check the action makes — authorization, the roster and pending-invitation rules — without writing, mailing or dispatching events, so the preview matches the real run.
invitationId is the invitation's id and expiresAt its expiry; the token and accept link never appear in the output. Refusal codes: missing_actor, unknown_actor, missing_roles, unknown_role, unsupported_configuration, database, the first validation key (email, roles), and the action's own (not_member, not_manager, not_grantable, plus the account guard's).
concierge:import-members
Copies an existing pivot into Concierge's own workspace engine; app-wide mode and the kit engine refuse it. It never changes the source pivot. Ownership and existing memberships are kept; each workspace's writes are transactional, so a later failure leaves earlier workspaces imported.
Option
Behaviour / default
--model=<class>
concierge.models.workspace; must match that setting.
--pivot=<table>
Laravel's pivot convention, e.g. organisation_user.
--foreign-key=<column>
Workspace key in the pivot; <model>_id.
--user-key=<column>
User key in the pivot; user_id.
--role-column=<column>
Optional source role column.
--owner-role=<value>
Source value marking the owner; concierge.owner_role.
--owner-column=<column>
Fallback owner key on the workspace; owner_id, ignored when absent.
--map=<from>:<to>
Repeatable source-to-Concierge role mapping.
--default-role=<role>
Role for unmapped members, or all members without a role column.
Each JSON workspace has id, name, owner and members. The owner has user_id, email, source and action (establish or keep), with an optional note when promoting an existing member. A member has user_id, email, source_role, role and action: import includes joined_at; skip includes reason. summary counts workspaces, owners, members and skipped. Dry-run reports the same planned actions with dry_run: true.
concierge:roles {email}
Changes a user's app-wide roles through ChangeUserRoles, running as the --as user: the same rules — not yourself, not the last owner, not someone of equal or higher rank, a role the actor may grant, and the host AccountGuard's veto. App-wide mode only.
Option
Behaviour
--role=<name>
A role the user ends up with; repeat for several. At least one is required.
--as=<email>
The existing user the action runs as; required. Every rule the action applies holds unchanged.
--dry-run
Resolve both users and run the action's authorization check without writing or dispatching events.
--json
{user, outcome, roles}; refusals: {error, code}.
user is the target's email, roles lists the resulting role names. Outcomes: changed, unchanged; dry-run: would_change, unchanged. Refusal codes: missing_actor, unknown_actor, unknown_user, missing_roles, unknown_role, unsupported_configuration, database, and the action's own (self, rank, not_grantable, not_manager, not_member, last_owner, plus the account guard's).
concierge:pages [name]
Writes the four Inertia wrapper pages using mod. name is the folder under the app's pages directory, defaulting to Concierge; on a modules layout use Billing:Concierge or --in=Billing.
Option
Behaviour
--in=<group>
Layout group or module to place the pages in.
--force
Overwrite existing pages. Takes precedence over --skip-existing.
--skip-existing
Keep existing pages and write the rest.
--dry-run
Preview without writing. Refused plans also exit non-zero.
--json
Mod's plan plus dry_run, written and skipped path arrays.
The JSON keys are command, group, name, files, inserts, warnings, would_write, dry_run, written and skipped. files describes each planned page (alias, type, path, identity, group, existing, exists); warnings carries mod's diagnostic messages. Dry-run leaves written empty; files shows what would be written and skipped what would be kept. A refused plan adds error and writes nothing. A failure during generation returns {error}; it may have written earlier files. Without either collision flag, existing pages are refused rather than prompting.
concierge:deactivate <email>
Deactivates a user as the --as user: the same DeactivateUser action the Users page calls, with the same rules — not yourself, not the last owner, not someone of equal or higher rank, and the host AccountGuard's veto. App-wide mode only.
Option
Behaviour
--as=<email>
The existing user the action runs as; required. Every rule the action applies holds unchanged.
--reason=<text>
Why the user is deactivated; stored on the account, truncated to 500 characters.
--dry-run
Resolve both users and run the action's authorization check without writing, mailing or dispatching events.
user carries id and email. Outcomes: deactivated; dry-run: would_deactivate. sessionsEnded is what the action ended on the database session driver, and null on other drivers and on a dry run. Refusal codes: missing_actor, unknown_actor, unknown_user, unsupported_configuration, database, and the action's own (already_deactivated, self, rank, not_manager, not_member, last_owner, plus the account guard's).
concierge:restore <email>
Restores a deactivated user as the --as user: the same RestoreUser action the Users page calls, with the same rules as deactivating — not yourself, not someone of equal or higher rank, and the host AccountGuard's veto. The user's roles come back as they left them, and no invitation is sent. App-wide mode only.
Option
Behaviour
--as=<email>
The existing user the action runs as; required. Every rule the action applies holds unchanged.
--dry-run
Resolve both users and run the action's authorization check without writing or dispatching events.
--json
{user, outcome}; refusals: {error, code}.
user carries id and email. Outcomes: restored; dry-run: would_restore. Refusal codes: missing_actor, unknown_actor, unknown_user, unsupported_configuration, database, and the action's own (not_deactivated, self, rank, not_manager, not_member, last_owner, plus the account guard's).
Usage
Membership
Every operation is an action class in Tey\Concierge\Actions, called with ::run(): invite, resend, revoke and accept invitations; change roles; suspend, reinstate and remove members; leave a workspace (LeaveWorkspace::run($user, $workspace): a member ends their own membership, and the owner must transfer ownership first); transfer ownership; force a password reset. Actions check the grant rules in config/concierge.php and dispatch events. Your app keeps its routes, controllers, policies and authentication, and protects routes with the concierge.member middleware. See Membership.
concierge.member returns 404 without a workspace and for a workspace the user never belonged to. Once a user is removed, leaves or is suspended, it repoints CurrentWorkspace (the session's current workspace) to their oldest other active membership, or to none, and redirects to workspaces.no_workspace_url (default /); JSON requests get a 403.
InviteMember::run($actor, $workspace, $email, $grants, message: 'Welcome') and ResendInvitation::run($actor, $invitation, message: ...) take an optional message of up to 500 characters; it is on the invitation, its InvitationRow and the delivered notification. CopyInvitationLink::run($actor, $invitation) rotates the token for a link you hand over yourself: the previous link stops working and nothing is emailed. MembersIndex offers it as the copy_link row action (route name invitation.copyLink) to anyone who may resend.
When the wrong account opens an invitation, the page names both emails and offers Switch account and Cancel (switchAccount and cancel links, signedInAs prop). Point Switch account at a POST route that returns InvitationHandoff::switchAccount($request, $token, $returnTo, route('login')): it signs out and keeps the invitation in an encrypted cookie for 30 minutes, and the next sign-in or registration returns to $returnTo once. For a signed-out visitor, call InvitationHandoff::start($token, $returnTo) before sending them to sign in. The handoff also stands in for the register link in invitation and domains registration modes. Clear it with InvitationHandoff::forget() once accepted.
DeactivateUser::run($actor, $user, reason: 'Left the company') takes a user's access away and keeps their history; RestoreUser::run($actor, $user) gives it back. The state lives on the user's concierge_accounts row, not on the users table, so your own queries are untouched: filter on status when you want to hide deactivated people. The same rules as removal apply through the AccountPolicy and AccountGuard: not yourself, not the last owner, not someone of equal or higher rank. Roles and, in workspace mode, memberships stay as they are, so restoring brings back exactly what the user had, with no new invitation. A listener on Laravel's Validated event refuses a deactivated user with "This account is deactivated." on the email key once their credentials check out, which covers Fortify, the starter kits and Auth::attempt. Add the concierge.active middleware after your authentication middleware (for example ['auth:sanctum', 'concierge.active']) to sign out a deactivated user on their next request; token requests get a 403 and the tokens are kept, so they work again after a restore. Deactivating also deletes the user's sessions when they live in the database driver and cycles the remember-me token; with other drivers the middleware ends the session. Workspace mode has no app-wide roles, so bind your own AccountPolicy to say who may deactivate. Listen to UserDeactivated and UserRestored.
ForcePasswordReset::run($actor, $membership, resetTwoFactor: true) locks an account down when it may be compromised: the password becomes one nobody knows, the remember-me token is cycled, two-factor is cleared when asked, and the person receives a reset link through your password broker. The same manager and rank rules apply as for suspending, and never to yourself. With the database session driver the person's sessions are deleted; with any other driver, add Laravel's AuthenticateSession middleware so their sessions end when the password hash changes. API tokens are untouched; listen to PasswordResetForced. In app-wide mode pass the user instead of a membership, ForcePasswordReset::run($actor, $user): the rules are those of DeactivateUser, a deactivated account is refused, and PasswordResetForced carries a null workspaceId and membershipId (so does the membership on the result).
Tenant Invitations
For an invite-only app: a platform owner invites an email, and accepting creates that person's own workspace, which they own. It is off by default and ships no routes; your app provides the workspace factory, the link and the register-or-sign-in pages. See Tenant invitations.
Existing Teams
An app that already has a team or organisation model can use it as the workspace. Add implements Workspace and the ActsAsWorkspace trait (the name comes from a name column, and the workspace accepts members unless it is soft-deleted).
A starter kit's teams (laravel new --teams): set 'workspaces' => ['engine' => 'kit'] and point models.workspace at the kit's Team. Concierge reads each team's members and their team_members.role, resolves the current team from {current_team} or users.current_team_id, and never writes the kit's tables. Protect team routes with concierge.member: it reads kit membership, returns 403 for a non-member and 404 without a workspace. Spatie teams stays off. On the user model, HasRoles and the kit's HasTeams both define teams(), so keep the kit's: use HasRoles, HasTeams { HasTeams::teams insteadof HasRoles; }. Concierge's own write actions refuse a kit team for now (#57).
Any other model with a pivot stays on concierge's own engine (add HasMembers too, with Spatie teams on). Copy its members in once with php artisan concierge:import-members --pivot=organisation_user --role-column=role --dry-run --json. The command picks an owner, in this order: the role column's owner, the model's owner_id, then the earliest member. It never changes the pivot, and running it again finds nothing to do.
Local Login
One click signs in as the account in your configuration, in the local environment only, checked on every request. It works without membership. See Local login for the exact rules, including cached configuration.
With membership enabled, local_login.first_owner defaults to true: when the app has no owner, the signed-in account becomes its owner through EstablishOwner. In workspace mode it becomes the owner of the oldest workspace through EstablishWorkspace, only if that workspace has no owner. No workspace means no grant, and sign-in still succeeds; local login never creates a workspace. An existing owner means no roles or memberships change, even for a different configured account. Set 'first_owner' => false in local_login to grant nothing. Plain-account guards and read-only starter-kit teams are unchanged.
The grant uses the same fail-closed environment checks as sign-in: both Laravel and the uncached process APP_ENV must be exactly local, including with cached configuration. It never grants ownership outside local, and does not mark the session as recently authenticated.
Vue Pages
MembersIndex builds the whole members page in workspace mode: app(MembersIndex::class)->urlsUsing($urls)->props($actor, $workspace, $request) returns MembersPageProps with a Members and an Invitations tab (?tab), each tab's count (which follows the active search), and the active tab's rows as a server-driven table (table: rows, columns, sort, search, filters, pagination and an empty state decided on the server). The query is spatie/laravel-query-builder: ?sort=name|joined (email|sent|expires for invitations, with - for descending), ?filter[search], ?filter[role], ?filter[status], ?page and ?per_page (capped at 100); an unknown sort, filter or tab is a 400 and nothing is silently ignored. Every row carries only the actions the grant rules let the viewer take (actions, each with its URL, method and, for destructive ones, the words to confirm with), so an admin gets none on the owner and nothing is shown disabled. $urls is fn (string $action, ?Model $subject): ?string, naming where each action goes in your app (index, invite, member.show, member.updateGrants, member.suspend, member.reinstate, member.remove, member.transferOwnership, invitation.resend, invitation.revoke); an action with no URL is left out. App-wide mode lists users instead and refuses it.
Optional Vue 3 pages (MembersPage, InviteMemberPage, MemberPage, UsersPage, InviteUserPage, UserPage, AcceptInvitationPage) and LocalLoginButton ship in the package under resources/js. The Members pages are for workspaces (MembersPage shows the tabs when the page props carry them); the Users pages are for app-wide mode. The PHP code never depends on them. In a Vue starter kit app, php artisan concierge:pages writes the four wrapper pages into your own resources/js/pages/Concierge, ready to edit (--dry-run previews them; mod lists the same recipe as mod:concierge/pages). Concierge declares the @tey/concierge alias, its TypeScript paths, its Tailwind source and its stylesheet to mod, so php artisan mod:install inertia --dry-run shows the wiring and mod:install inertia applies it to a starter kit app. In an app you wire by hand, add @import '@tey/concierge/styles.css'; to your CSS entry. See Vue pages for the Vite, Tailwind and plugin setup by hand.
TypeScript types
Type your own Members, Member, invite and accept pages against the generated page props your controllers supply. Vue apps can import them from @tey/concierge; plain TypeScript or other frontends can use @tey/concierge/types, which imports no Vue components and needs no Vue tooling. Both entries export the same generated types, including the Users page props and row types. Mod's existing @tey/concierge/* TypeScript path covers /types; no extra wiring is needed. The types.ts contract will be frozen at 1.0 (#36).
import type { MembersPageProps } from '@tey/concierge';
Or, without Vue tooling:
import type {
MembersPageProps,
MemberPageProps,
InviteMemberPageProps,
AcceptInvitationPageProps,
} from '@tey/concierge/types';
Users Pages (App-Wide Mode)
UsersPage lists everyone with search, a role filter and a status filter (Active by default, Deactivated, or both), paginated; set metadata.status to null or 'active' to hide deactivated users, 'deactivated' for only them and 'all' for everyone. UserPage edits a user's name, email and roles and links to removal, and InviteUserPage sends the app-wide invitation. Their props are UsersPageProps, UserPageProps and InviteUserPageProps in types.ts, and they never say "workspace". Concierge ships no routes yet (#28), so your controllers build the props and supply every link; a null link hides its control. Wire them to ChangeUserRoles, RemoveUser, InviteUser and UpdateUser. Changing an email does not clear email_verified_at or send a verification email; that is your app's call. Removal calls delete() on the user model, so a model with SoftDeletes is soft deleted. UserPage and the rows of UsersPage offer Deactivate (with an optional reason, sent as reason) and Restore when UserLinks carries a deactivate or restore link; both POST, so point them at routes that call DeactivateUser and RestoreUser. They also offer Force password reset when UserLinks carries a forcePasswordReset link (hidden for yourself and for deactivated users): it POSTs to a route that calls ForcePasswordReset, and sends resetTwoFactor: true when the person ticked "Also reset two-factor authentication", a box shown only when the row's twoFactorResettable is true. Your controller decides which users to list: filter on the account status to match the metadata.
UpdateUser::run($actor, $user, name: ..., email: ...) edits another user's name and email under the same rules as removal; your own profile stays with your app's profile page. A null argument leaves that field alone. The name is required (255 characters at most) and the email must be valid and unique on the users table, ignoring case, or a ValidationException keyed name or email is thrown. It writes the model's name and email attributes, which conciergeDisplayName() and conciergeEmail() read, and dispatches UserUpdated with the old and new value of each changed field.
To add your own columns, name them and return their values for each user, then put the same values on each UserRow:
// app/Providers/AppServiceProvider.php
use Tey\Concierge\Facades\Concierge;
Concierge::userColumns(
['plan' => 'Plan', 'seats' => 'Seats'],
fn ($user) => ['plan' => $user->plan, 'seats' => $user->seats],
);
// In the controller: send the labels in the metadata and the values on each row.
new UsersMetadata(columns: Concierge::userColumnDefinitions(), /* ... */);
new UserRow(columns: Concierge::userColumnValues($user), /* ... */);
Columns show after Joined. Render a cell yourself with the column slot, which receives column, user and value:
<UsersPage v-bind="props">
<template #column="{ column, value }">
<PlanBadge v-if="column.key === 'plan'" :plan="value" />
<template v-else>{{ value }}</template>
</template>
</UsersPage>
AcceptInvitationPage says "Join {app name}" when the invitation has no workspace; pass the name in metadata.appName, or leave it out for generic "Join" wording.
Every person on these pages has an avatar: the app's photo when it has one, and otherwise their initials on a colour that stays the same for that person everywhere. Nothing is fetched from an outside service. Build a UserSummary with UserSummary::for($user) and tell concierge where the photo lives:
// app/Providers/AppServiceProvider.php
use Tey\Concierge\Facades\Concierge;
Concierge::avatarUsing(fn ($user) => $user->profile_photo_url); // Jetstream
The Avatar component is exported for your own pages, and the eight --concierge-avatar-* colours are CSS variables you can override.
Configuration
The published config/concierge.php lists every key with a comment on each: the models, the roles and who may grant them, invitation expiry, tenant invitations and local login. Each feature page explains the keys it uses.
Who may register is one setting, CONCIERGE_REGISTRATION (open by default, domains, invitation or closed), enforced in Fortify's registration with no app code. See Registration.
FAQ
How Is This Different from spatie/laravel-permission, Starter-Kit Teams or an Admin Panel?
spatie/laravel-permission is the roles and permissions engine, and concierge builds on it. Concierge adds what sits on top: invitations, ownership, rules for who may grant which role, suspension, and the events for each.
Laravel's starter kits give an app sign-in, registration and, when generated with them, teams. Concierge picks up from there with membership administration as a package, so it updates with composer update. The roadmap shows how it plans to work with each kit's own teams.
An admin panel gives you screens over your tables. Concierge gives you the flows themselves (invitation expiry, ownership transfer, grant rules), which an admin panel can call.
What Does My App Still Provide?
Routes, controllers and policies that call the actions; authentication; and your layout. Each feature page lists exactly what it expects.