# Concierge for Laravel: full documentation > A standard suite of essentials for every Laravel app you build. Every page of https://concierge.teylabs.com in sidebar order, as Markdown. The index is https://concierge.teylabs.com/llms.txt. --- # Introduction 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. ```bash composer require tey/concierge ``` > [!NOTE] > Concierge is pre-1.0. Minor releases may change the API until 1.0. ## What Ships Today Each feature is set in `config/concierge.php`. | Feature | What you get | Default | | --- | --- | --- | | [Membership](/features/membership) | Invitations, memberships, ownership and role grants scoped to a workspace | On | | [Tenant invitations](/features/tenant-invitations) | A platform owner invites an email; accepting gives that person their own new workspace | Off | | [Registration](/features/registration) | Who may create an account: anyone, allowed email domains, invitation only, or no one | `open` | | [Forced password reset](/features/forced-password-reset) | A manager locks an account down and the person receives a reset link | With membership | | [Local login](/features/local-login) | One-click sign-in as a configured account, in the `local` environment only | Off | [Vue pages](/features/vue-pages) for membership, users and a local-login button are included as optional Vue 3 components, with [avatars](/features/avatars) for people without a photo. The PHP code never depends on them. Three early, opt-in features arrived in 0.7, ahead of 0.8: [app-wide mode](/features/app-wide-mode) for an app with one organization, [starter kit teams](/features/starter-kit-teams), and the [Users](/features/users) pages with deactivation. Local login and registration work without membership, so an app can use either alone. ## What Your App Provides Concierge provides the [actions and events](/reference/actions-and-events), models and optional Vue pages. Your app keeps its own: - routes, controllers and policies that call the actions - authentication: login, registration, password confirmation (a starter kit or Fortify) - layout, navigation and flash messages Each feature page lists exactly what it expects from the app. ## What Comes Next The road to 1.0 is planned in public: concierge as a starter kit extension, built on mod, with app-wide user management, setup in one command, and pages scaffolded into each starter kit with its own components. See [What concierge covers](/roadmap). --- # Installation Concierge requires PHP 8.4 or 8.5 and Laravel 13. ```bash composer require tey/concierge ``` The service provider is registered through Laravel package discovery. Composer also installs [spatie/laravel-permission](https://spatie.be/docs/laravel-permission) 8.x, which membership's roles build on. Composer also installs [mod](https://mod.teylabs.com) (`tey/mod` ^0.6.1), 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. [Vue pages](/features/vue-pages) shows how `mod:install inertia` wires concierge's frontend. ## Setting Up ```bash php artisan concierge:install ``` `concierge:install` detects your starter kit (Vue, React, Svelte, Livewire or none, and WorkOS AuthKit), your sign-in, your user model and any team or organization model. It asks only what it can't detect: - whether to use a team model it found as workspaces (`--workspace-model=`); - app-wide or workspace mode, app-wide by default (`--mode=`); - whether to turn on [local login](/features/local-login) when the app has no sign-in (`--local-login`). `--no-interaction` takes the defaults, and `--dry-run --json` shows the plan without changing anything. The command then publishes and configures Spatie permission (teams on only for workspace mode on concierge's own engine), writes `config/concierge.php`, migrates and creates the configured roles. It prints one line per step: ```text permission-config: done (published config/permission.php) permission-teams: done (teams off) permission-migrations: done (published create_permission_tables) concierge-config: done (wrote config/concierge.php, mode app) user-model: already (App\Models\User implements Member) migrate: done (migrated) roles: done (owner, admin, member) first-owner: manual (set CONCIERGE_OWNERS and run php artisan concierge:owners) ``` It never edits your models. If the user or workspace model is missing concierge's contract, it prints the exact lines to add and stops before migrating; add them and run it again. Running it again changes nothing that is already set up. [Membership](/features/membership) explains the contract methods, and [App-wide mode](/features/app-wide-mode) the mode with one organization. ## Before Setup `composer require tey/concierge` alone doesn't change your app. Until `config/concierge.php` exists, concierge registers no migrations, routes, middleware, listeners or commands other than `concierge:install`, so `php artisan migrate` creates no concierge tables. `php artisan about` shows whether concierge is set up and, when it isn't, the command to run. To keep concierge unregistered while the file exists, set `'setup' => false` in it. ## Choosing Features An app that only wants [local login](/features/local-login) turns membership off in `config/concierge.php`: ```php // config/concierge.php 'features' => ['membership' => false], ``` With membership off, `php artisan migrate` needs no workspace or permission setup. | Feature | Switch | Default | | --- | --- | --- | | [Membership](/features/membership) | `features.membership` | `true` | | [Tenant invitations](/features/tenant-invitations) | `features.tenant_invitations` | `false` | | [Local login](/features/local-login) | `local_login.enabled` | `false` | [Registration](/features/registration) is not a switch but a mode, `CONCIERGE_REGISTRATION`, and it defaults to `open`. The feature switches are read while the package provider registers, so set them in `config/concierge.php`, not at runtime. ## Upgrading from 0.9 Nothing to do. An app that already uses concierge has its own `config/concierge.php`, so it counts as set up and behaves as before. `concierge:install` on such an app reads its mode and models from the config, reports what is in place, and changes nothing it finds set up. --- # Membership Membership gives an app invitations, memberships, ownership and role grants, each scoped to a workspace: the team or organization a user belongs to. It is on by default and builds on [spatie/laravel-permission](https://spatie.be/docs/laravel-permission) teams. This page covers workspace mode. An app with one organization can use [app-wide mode](/features/app-wide-mode) instead, and an app with a starter kit's teams can use [starter kit teams](/features/starter-kit-teams). ## Requirements - Spatie permission **teams enabled** (`permission.teams = true`). Concierge throws `UnsupportedConfiguration` otherwise. - Incrementing integer keys on the user and workspace tables. The package migration uses `foreignId`. - A workspace model, even for a single-organization app: create exactly one workspace and use it everywhere. ## Setting Up, in Order The package registers its migration automatically, and that migration reads your user and workspace models and tables. Any `php artisan migrate` after installing the package runs it, so put everything it reads in place first: 1. Define your user and workspace models and their migrations, and implement the [contracts](#implementing-the-contracts). 2. Publish the Spatie permission config and migrations, and set `permission.teams` to `true`. 3. Publish `config/concierge.php`, and set workspace mode and the two models: ```php // config/concierge.php 'mode' => 'workspaces', 'models' => [ 'user' => App\Models\User::class, 'workspace' => App\Models\Workspace::class, ], ``` 4. Migrate, then create the configured roles: ```bash php artisan migrate php artisan concierge:sync-roles ``` If `migrate` runs before step 3, the Concierge migration fails with an `UnsupportedConfiguration` error naming the missing model setting. ## Implementing the Contracts The user model implements `Tey\Concierge\Contracts\Member` and uses Spatie's `HasRoles`: ```php // app/Models/User.php email; } public function conciergeHasVerifiedEmail(): bool { return $this->email_verified_at !== null; } public function conciergeDisplayName(): string { return $this->name; } } ``` The workspace model implements `Tey\Concierge\Contracts\Workspace`: ```php // app/Models/Workspace.php name; } public function conciergeAcceptsMembers(): bool { return true; } } ``` ## Roles Roles are listed in `config/concierge.php`, highest rank first. Each role names the roles allowed to grant it: ```php // config/concierge.php 'roles' => [ 'owner' => ['label' => 'Owner', 'grantable_by' => []], 'admin' => ['label' => 'Admin', 'grantable_by' => ['owner']], 'presenter' => ['label' => 'Presenter', 'grantable_by' => ['owner', 'admin']], ], ``` Each role is a global Spatie role, and every assignment is scoped to a workspace. Run `php artisan concierge:sync-roles` after changing the list. The owner role, the role a previous owner keeps after a transfer, the roles that may manage members and the invitation timings are set in the same file; see [Configuration](/reference/configuration#roles). ## Actions Every operation is an action class in `Tey\Concierge\Actions`, called with `::run()`. Actions that change a membership take the acting user first and check the grant rules before writing. Start by making a user the owner of a new workspace: ```php 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); ``` The other actions invite, resend, revoke, inspect and accept invitations, and change roles, suspend, reinstate, remove, transfer ownership and [force a password reset](/features/forced-password-reset). Each one's arguments, rules, results and exceptions, and the events they dispatch, are listed in [Actions and Events](/reference/actions-and-events). ## Protecting Routes The `concierge.member` middleware allows a request only from an active member of the current workspace. With no current workspace it responds 404; for anyone else it responds 403. ```php // routes/web.php Route::middleware(['auth', 'concierge.member'])->group(function () { // ... }); ``` The current workspace defaults to the current Spatie permission team. ## What Your App Provides Concierge provides the domain actions, events, models and optional [Vue pages](/features/vue-pages), with [avatars](/features/avatars) for the people on them. Your app provides: - controllers, routes and policies that call the actions - authentication (login, Fortify, password setup and confirmation) - the workspace context, and creating the first workspace and its owner - the invitation URL. Without it, no invitation email is sent: ```php // app/Providers/AppServiceProvider.php → boot() use Tey\Concierge\Facades\Concierge; Concierge::invitationUrlUsing(fn ($invitation, string $token) => url("/invitations/{$token}")); ``` Hiding a control in the UI is not authorization: enforce every action on the server. --- # App-Wide Mode App-wide mode is for an app with one organization. Invitations and roles apply to the whole app: there is no workspace model or table, and Spatie teams stay off. > [!TIP] > App-wide mode is an early feature. It is opt-in, and it completes in 0.8, which adds the upgrade from app-wide mode to workspaces ([#19](https://github.com/teylabs/concierge/issues/19)). Until then, its API may change. ## Choosing the Mode The `mode` key in `config/concierge.php` says where invitations and roles apply: | Value | Invitations and roles apply to | Spatie teams | | --- | --- | --- | | `'app'` | The whole app | Off (`permission.teams = false`) | | `'workspaces'` | One workspace at a time, as in [membership](/features/membership) | On, except with [starter kit teams](/features/starter-kit-teams) | Set it in the file itself, never from `.env`: it describes your schema, which every environment shares. Any other value throws `UnsupportedConfiguration` when the app boots. Leaving the key unset changes nothing for an existing app. A config with no `mode` (or `null`) is app-wide, except for this transition rule: when `models.workspace` is set, the mode is `'workspaces'`. So an app set up for membership before 0.7 stays in workspace mode. `php artisan about` shows the mode, and flags one that came from the transition rule. The rule is removed at 1.0, so set `'mode' => 'workspaces'` before then. ## Setting Up 1. Implement `Tey\Concierge\Contracts\Member` on your user model, with Spatie's `HasRoles`, as in [Implementing the Contracts](/features/membership#implementing-the-contracts). The users table needs an incrementing integer key. 2. Publish the [spatie/laravel-permission](https://spatie.be/docs/laravel-permission) config and migrations, and leave `permission.teams` set to `false`. 3. Set the mode and the user model, and leave `models.workspace` empty: ```php // config/concierge.php 'mode' => 'app', 'models' => [ 'user' => App\Models\User::class, 'workspace' => null, ], ``` 4. Migrate, then create the configured roles: ```bash php artisan migrate php artisan concierge:sync-roles ``` 5. Make the first owner: ```php use App\Models\User; use Tey\Concierge\Actions\EstablishOwner; EstablishOwner::run(User::where('email', 'owner@example.com')->firstOrFail()); ``` With `permission.teams = true`, `concierge:sync-roles` and `EstablishOwner` throw `UnsupportedConfiguration`. [Tenant invitations](/features/tenant-invitations) give each invitee a workspace, so they need workspace mode: turning them on in app-wide mode fails at boot. ## The Accounts Table The users table is the roster. Concierge keeps its own state for each user in a `concierge_accounts` row beside it, in both modes: | Column | What it holds | | --- | --- | | `user_id` | The user's key, and the primary key. Deleting the user is restricted while the row exists; see [Deleting Accounts](#deleting-accounts) | | `status` | `active` or `deactivated` ([Users](/features/users#deactivating-and-restoring)) | | `invitation_id` | The invitation the user accepted, if any | | `joined_at` | When they became an owner or accepted an invitation | | `deactivated_at`, `deactivated_by_id`, `deactivation_reason` | Set while the account is deactivated | In app-wide mode the migration creates `concierge_invitations` and `concierge_accounts`, with no membership or ownership tables. An app that installed an earlier release gets `concierge_accounts` on its next `php artisan migrate`. A user without a row is an active account. Concierge creates the row when it first acts on that user, so your existing users need no backfill. `Concierge::account($user)` returns the row, or an unsaved active one. The model is `Tey\Concierge\Models\Account`; swap it for a subclass with `models.account`. ## Actions Each is in `Tey\Concierge\Actions` and called with `::run()`. [Actions and Events](/reference/actions-and-events#app-wide-actions) lists their exceptions and events. | Action | What it does | | --- | --- | | `EstablishOwner::run($user)` | Makes `$user` an active owner. It replaces their other roles with the owner role. It takes no actor, so call it from your own code, a command or a deploy script. Calling it again changes nothing. | | `InviteUser::run($actor, $email, $grants)` | Invites an email into the app with the roles in a `GrantSet`. An email that already has an account is refused: change that user's roles instead, or restore them if they are deactivated. | | `ChangeUserRoles::run($actor, $user, $grants)` | Replaces the user's roles. | | `RemoveUser::run($actor, $user)` | Clears the user's roles and direct permissions, deletes their account row, then calls `delete()` on the user model. A model with `SoftDeletes` is soft deleted. | `AcceptInvitation` and `InspectInvitation` work in both modes. In app-wide mode, accepting grants the invitation's roles to the whole app and records the invitation on the account row. `ResendInvitation` and `RevokeInvitation` are for workspace invitations only for now ([#79](https://github.com/teylabs/concierge/issues/79)). The workspace actions (`EstablishWorkspace`, `InviteMember`, `ChangeMemberRoles` and the rest) throw `UnsupportedConfiguration` in app-wide mode, naming the app-wide action to use. The app-wide actions do the same in workspace mode. The [Users pages](/features/users) and `UpdateUser`, `DeactivateUser` and `RestoreUser` build on the same rules. ## Who May Manage Whom The default `Tey\Concierge\Contracts\AccountPolicy` reads the same `roles`, `managers` and ranks as the [grant policy](/reference/actions-and-events#who-may-call-them): - The actor must have an active account and hold one of the `managers` roles. - Nobody manages their own account. - The actor must outrank the user, except that an owner may manage another owner. - A role can be granted only by the roles in its `grantable_by`. An owner may also grant the owner role to another user. - Your [account guard](/reference/actions-and-events#account-guard) is asked with `AccountGuard::MANAGE_ACCOUNT` and may refuse. Owner is the top role, and several owners are allowed. Granting the owner role with `ChangeUserRoles` is how ownership passes on: there is no `TransferOwnership` in app-wide mode. Bind your own class to `AccountPolicy` in a service provider to change these rules. ### The Last Owner The last owner can never be removed or demoted. When an owner acts on another owner, and no other active owner would remain, the policy refuses with the code `last_owner`. Owners your account guard shields from management, such as an operator account, are not counted, so such an account is never left as the only owner. Every app-wide write locks the account rows of every owner, the actor and the user before it checks, so two requests that could each remove an owner can't both succeed. ## Protecting Routes The `concierge.member` middleware works in both modes. In app-wide mode it allows a request from any user whose account is active, and responds 403 otherwise. ## Deleting Accounts Your app's own "delete account" form keeps working. When a user model is deleted, concierge deletes their account row first. A soft delete keeps the row, so restoring the user keeps their state. In app-wide mode, deleting the last active owner is refused with a `ValidationException` on the `password` key: "This account is the last owner; make someone else an owner before deleting it." A starter kit's delete-account form shows errors on its password field, so the person sees the message instead of an error page. --- # Starter Kit Teams An app that already has a team or organization model can use it as concierge's workspace. A Laravel starter kit generated with teams (`laravel new --teams`) is read through the kit's own tables. Any other model with a membership pivot is copied into concierge's own tables once, with `concierge:import-members`. > [!TIP] > Starter kit teams are an early feature, and opt-in. For now concierge reads a kit's teams and doesn't change them: its pages and write actions for kit teams come later ([#57](https://github.com/teylabs/concierge/issues/57)). Until then, the API may change. ## The Workspace Engine In workspace mode, `workspaces.engine` says who keeps each workspace's members: | Value | Members are kept in | Spatie teams | | --- | --- | --- | | `'concierge'` (default) | Concierge's own tables, as in [membership](/features/membership) | On (`permission.teams = true`) | | `'kit'` | The starter kit's `team_members` table, read and never written | Off (`permission.teams = false`) | Set it in `config/concierge.php`, never from `.env`. A missing or `null` value is `'concierge'`, and any other value throws `UnsupportedConfiguration`. The engine is read in workspace mode only; `php artisan about` shows it. ## ActsAsWorkspace `Tey\Concierge\Concerns\ActsAsWorkspace` implements the `Workspace` contract's two methods for an existing model: the name comes from a `name` column, and the workspace accepts members unless it is soft-deleted. Override either method to change that. ```php // app/Models/Team.php use Tey\Concierge\Concerns\ActsAsWorkspace; use Tey\Concierge\Contracts\Workspace; class Team extends Model implements Workspace { use ActsAsWorkspace; // ...the kit's own relations } ``` A kit's `Team` already has its own memberships and owner, so it takes `ActsAsWorkspace` alone. A model on concierge's own engine adds `Tey\Concierge\Concerns\HasMembers` as well. ## Using a Starter Kit's Teams 1. Add `implements Workspace` and `ActsAsWorkspace` to the kit's `App\Models\Team`, as above. 2. On the user model, implement `Member` and add `HasRoles`. Spatie's `HasRoles` and the kit's `HasTeams` both define `teams()`, so keep the kit's: ```php // app/Models/User.php use App\Concerns\HasTeams; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable implements Member { use HasRoles, HasTeams { HasTeams::teams insteadof HasRoles; } // ...the Member methods } ``` 3. Leave `permission.teams` set to `false`. The kit engine with Spatie teams on throws `UnsupportedConfiguration`; the kit's `team_members.role` holds the team roles. 4. Set the mode, the engine and the models: ```php // config/concierge.php 'mode' => 'workspaces', 'workspaces' => [ 'engine' => 'kit', ], 'models' => [ 'user' => App\Models\User::class, 'workspace' => App\Models\Team::class, ], ``` 5. Migrate, then sync the roles. Concierge's roles stay global Spatie roles for the app; the kit's roles (`owner`, `admin`, `member`) stay in its pivot. ```bash php artisan migrate php artisan concierge:sync-roles ``` ## The Kit Adapter With the kit engine, concierge binds two classes in place of its own: - `Tey\Concierge\Kit\KitMembershipSource`, bound to `Tey\Concierge\Contracts\MembershipSource`, reads a team's members through the team's `memberships()` relation, one person through the same relation, and a person's teams through the user's `teams()` relation. Each member has one role, from the pivot's `role` column. The member whose role is `owner` is the team's owner, and the pivot's `created_at` is when they joined. - `Tey\Concierge\Kit\KitWorkspaceContext`, bound to `WorkspaceContext`, finds the current team in the `{current_team}` route parameter (a bound model, or its route key such as a slug), and otherwise in the signed-in user's `current_team_id`. It never checks membership: the kit's own `EnsureTeamMembership` middleware does that. Protect kit team routes with that middleware, not `concierge.member`, which doesn't read kit teams yet ([#80](https://github.com/teylabs/concierge/issues/80)). The adapter reads and never writes. Concierge's write actions refuse a kit team with a `Tey\Concierge\Exceptions\WorkspaceNotManaged` exception that names [#57](https://github.com/teylabs/concierge/issues/57). Change a kit team's members with the kit's own team screens. If your app renamed the kit's relations or columns, extend either class, change its protected properties (`$membershipsRelation`, `$teamsRelation`, `$roleColumn`, `$ownerRole`, `$routeParameter`, `$currentTeamColumn`), and bind your class in a service provider. ## Importing Another Model's Members A team or organization model that isn't a starter kit's stays on concierge's own engine: 1. Add `implements Workspace` with the `ActsAsWorkspace` and `HasMembers` traits. 2. Turn Spatie teams on, and set `'mode' => 'workspaces'` and `models.workspace` to the model, as in [membership](/features/membership#setting-up-in-order). Migrate. 3. Copy the pivot's members in once. Preview first: ```bash php artisan concierge:import-members --pivot=organization_user --role-column=role --dry-run ``` Then run it without `--dry-run`. The command reads the pivot and never changes it. For each workspace without an owner it picks one, in this order: the earliest member whose role column holds the owner role, the user in the model's `owner_id` column, then the earliest member. A person who already has a concierge membership in that workspace is skipped, so running it again finds nothing to do. It syncs the configured roles before it writes. | Option | Default | What it does | | --- | --- | --- | | `--model=` | `models.workspace` | The team or organization model. It must match `models.workspace`. | | `--pivot=` | The Laravel convention, e.g. `organization_user` | The membership pivot table. | | `--foreign-key=` | `_id` | The pivot column holding the model's key. | | `--user-key=` | `user_id` | The pivot column holding the user's key. | | `--role-column=` | none | The pivot column holding each member's role. | | `--owner-role=` | `owner_role` | The role-column value that marks the owner. | | `--owner-column=` | `owner_id` | A column on the model's table holding the owner's user id, used when no member has the owner role. | | `--map=from:to` | none | Maps a role-column value to a concierge role, e.g. `--map=member:presenter`. Repeat it for each value. | | `--default-role=` | none | The concierge role for members whose role doesn't map, or for every member without `--role-column`. | | `--dry-run` | | Shows what would be imported and writes nothing. | | `--json` | | Prints the plan or the result as JSON. | A role-column value that matches a configured concierge role keeps that role. Other owners keep the `transfer_demotes_to` role, since a concierge workspace has one owner. Nothing can be mapped to the owner role. A value with no mapping and no `--default-role` stops the import before anything is written. When the command refuses, it prints the reason and the fix, or both as JSON with `--json`. It refuses in app-wide mode, with the kit engine (concierge already reads the kit's members), with Spatie teams off, and when the pivot or a named column doesn't exist. --- # Tenant Invitations Tenant invitations are for an invite-only SaaS app: a platform owner invites an email, and accepting gives that person their own new workspace, which they own. There are no plans, quotas or billing. They are separate from [membership](/features/membership) invitations, which bring someone into an existing workspace. Invitees can still use those inside their own workspace. ## Turning Them On Tenant invitations are off by default: no table, rate limiter or Gate definition exists until you opt in. Set this in `config/concierge.php` before publishing migrations: ```php // config/concierge.php 'features' => ['membership' => true, 'tenant_invitations' => true], 'tenant_invitations' => [ 'platform_owners' => ['owner@example.com'], // verified emails; or define the Gate ability yourself ], ``` Then publish the migration and migrate: ```bash php artisan vendor:publish --tag="concierge-migrations" php artisan migrate ``` The switch is read while the package provider registers, so it cannot be set at runtime. ## Actions | Action | Who | | --- | --- | | `InviteTenant::run($actor, $email)` | platform owner | | `ResendTenantInvitation::run($actor, $invitation)` | platform owner | | `RevokeTenantInvitation::run($actor, $invitation)` | platform owner | | `InspectTenantInvitation::run($user, $token)` | anyone holding the token | | `AcceptTenantInvitation::run($user, $token)` | the invitee | The actions live in `Tey\Concierge\Actions`. What each one returns, throws and dispatches, and the [invitation states](/reference/actions-and-events#invitation-states) the inspect action returns, are in [Actions and Events](/reference/actions-and-events#tenant-invitation-actions). The `TenantInvitation` model has `outstanding()`, `lapsed()`, `accepted()` and `revoked()` scopes and `workspace` and `inviter` relations: enough for an admin page of tenants and pending, accepted and revoked invitations. `concierge.models.tenant_invitation` swaps the model. `SignInRequired` and `RegisterRequired` reveal only whether the invited address has an account, only to someone holding a valid, pending token, and the inspect rate limiter bounds them. Invalid, expired, revoked and accepted tokens reveal nothing. ## Authorization Platform ownership lives outside workspaces and permission teams. Every action that changes something checks one Gate ability, [`tenant_invitations.ability`](/reference/configuration#tenant-invitations) (default `concierge.manage-tenants`): 1. Define it yourself, for full control: ```php // app/Providers/AppServiceProvider.php → boot() use Illuminate\Support\Facades\Gate; Gate::define('concierge.manage-tenants', fn ($user) => $user->email === 'owner@example.com'); ``` 2. Otherwise Concierge defines it from `tenant_invitations.platform_owners`: a list of emails, compared case-insensitively. The signed-in account must also have a verified email. 3. With neither, everyone is denied. Being a workspace owner or admin grants nothing here. The check goes through the Gate, so a `Gate::before` callback that returns `true` (a super-admin bypass, Spatie's included) also grants this ability. To keep super-admins out of tenant management, return `null` for it: ```php // app/Providers/AppServiceProvider.php → boot() use Illuminate\Support\Facades\Gate; Gate::before(function ($user, string $ability) { if ($ability === 'concierge.manage-tenants') { return null; // decided by the ability itself } // ...your super-admin check, returning true or null }); ``` ## What Your App Provides Concierge registers no routes for tenant invitations. Your app provides the following. **The workspace.** Tell Concierge how to create it. The callback runs inside the acceptance transaction, and the invitee then becomes its owner through `EstablishWorkspace`. If the callback throws, nothing is written and the invitation stays pending. ```php // app/Providers/AppServiceProvider.php → boot() use App\Models\User; use App\Models\Workspace; use Tey\Concierge\Facades\Concierge; use Tey\Concierge\Models\TenantInvitation; Concierge::provisionWorkspaceUsing(fn (User $owner, TenantInvitation $invitation) => Workspace::create([ 'name' => $owner->name."'s workspace", ])); ``` **The link.** Without it nothing is emailed; you can still read the token from `InviteTenant`'s result. ```php // app/Providers/AppServiceProvider.php → boot() use Tey\Concierge\Facades\Concierge; use Tey\Concierge\Models\TenantInvitation; Concierge::tenantInvitationUrlUsing(fn (TenantInvitation $invitation, string $token) => url("/join/{$token}")); ``` **Register or sign in.** Call `InspectTenantInvitation` on the link, lock your register form to the invitation email, sign the user in, then call `AcceptTenantInvitation` and redirect into their new workspace. Accepting requires the account email to match the invitation and to be verified. Opening the invitation proves control of the inbox, so your registration may mark the email verified when it comes from a valid, pending invitation for that same email; Concierge does not do that for you. **Routes outside the workspace.** The inspect and accept routes must sit outside any tenant or `concierge.member` middleware: there is no workspace yet. **Throttling.** Concierge defines named rate limiters; apply them to your routes. The token limiters read a route parameter named `token`. | Limiter | Applies per | Limit key | | --- | --- | --- | | `throttle:concierge-tenant-invitation-inspect` | IP and token | `tenant_invitations.throttle.inspect` | | `throttle:concierge-tenant-invitation-accept` | IP, token and signed-in user | `tenant_invitations.throttle.accept` | | `throttle:concierge-tenant-invitation-invite` | signed-in user (IP for a guest) | `tenant_invitations.throttle.invite` | The limits and their defaults are in [Configuration](/reference/configuration#tenant-invitations). ## Multitenancy Packages `Tey\Concierge\Jobs\DeliverTenantInvitation` is not tenant-aware, since an invitation exists before any workspace, and it resolves everything by id. An app using `spatie/laravel-multitenancy` with tenant-aware queues must list it, or the queue deletes the job for lacking a tenant and the email is never sent: ```php // config/multitenancy.php 'not_tenant_aware_jobs' => [Tey\Concierge\Jobs\DeliverTenantInvitation::class], ``` The events (`TenantInvited`, `TenantInvitationResent`, `TenantInvitationRevoked`, `TenantInvitationAccepted`) are dispatched after commit and carry ids only, never a model or the current tenant. A listener that needs the workspace (`TenantInvitationAccepted::$workspaceId`) must enter its context itself. --- # Registration Registration gating decides who may create an account through your app's registration form. It is one setting, read from the environment, and concierge enforces it where the app creates accounts. With Fortify, which the starter kits use, `open`, `closed` and domain-only registration take effect with no app code; letting invited people in takes one hidden field. It works with or without [membership](/features/membership). ```dotenv CONCIERGE_REGISTRATION=domains CONCIERGE_REGISTRATION_DOMAINS=acme.com,acme.io ``` ## Modes | Mode | Who may register | | --- | --- | | `open` | Anyone. The default, so an app that upgrades keeps the registration it had. | | `domains` | Anyone whose email is on one of `CONCIERGE_REGISTRATION_DOMAINS`, or who holds a valid invitation. | | `invitation` | Only someone with a valid invitation, for the email the invitation was sent to. | | `closed` | No one. The register page and the register request are a 404. Accounts come from your own code or a command. | The published `config/concierge.php` reads both settings under `registration`: ```php // config/concierge.php 'registration' => [ 'mode' => env('CONCIERGE_REGISTRATION', 'open'), 'domains' => env('CONCIERGE_REGISTRATION_DOMAINS', ''), ], ``` `domains` takes a comma-separated string or an array. A leading `@` and surrounding spaces are ignored, and case does not matter. ## Fails Closed Registration closes whenever the setting cannot be trusted: - A mode that isn't one of the four (a typo, an empty value, `null`) counts as `closed`. Only a mode that is not set at all counts as `open`. - `domains` mode with no domains counts as `closed`, and a warning in the log names `CONCIERGE_REGISTRATION_DOMAINS`. - In `closed` mode a valid invitation does not open the door. ## Domains The domain is the part of the email after the last `@`, compared case-insensitively, so `Jane@ACME.COM` matches `acme.com`. A subdomain matches only when it is listed itself: with `acme.com` alone, `jane@mail.acme.com` is refused. Someone on another domain can still register with a valid invitation. ## Invitations In `invitation` and `domains` modes, a valid invitation admits the person it was sent to. A valid invitation is a pending, unexpired membership invitation, or [tenant invitation](/features/tenant-invitations) when that feature is on, whose email matches the one being registered, case-insensitively. With membership off, no invitation admits anyone. The token is read from the request that creates the account, under `invitation`. It is the same token as in the invitation link, so a register page reached from `/register?invitation=TOKEN` should send it back in a hidden field named `invitation` ([With Fortify](#with-fortify) shows how). The token is checked on that request, not when the page is shown. Expired, revoked, already accepted and forged tokens, and a token for another email, are refused with a message that says what to do. Registering does not accept the invitation. The person still verifies their email and accepts through your accept route, as before. Once accepted, the token no longer admits anyone. ## With Fortify When `laravel/fortify` is installed, concierge wraps the user creator the app binds with `Fortify::createUsersUsing()` and the register view it binds with `Fortify::registerView()`. The gate runs without changes to your app's actions or routes: - A refusal is a validation error on the email field, shown by your kit's own form. The kit's own validation still runs after the gate. - In `closed` mode the register page and `POST /register` are a 404. - The wrapping works whichever service provider boots first. - The mode is read on each request, so changing it needs no route rebuild. With a cached configuration, run `php artisan config:cache` again after changing it. - In `invitation` mode, and for invitees outside your domains in `domains` mode, the kit's register form has to send the invitation token back. Add a hidden `invitation` field, filled from `?invitation=` on the register page. Concierge will carry it for you once [#67](https://github.com/teylabs/concierge/issues/67) lands. In the Vue starter kit, add the field to the register form in `resources/js/pages/auth/Register.vue`: ```vue ``` Reading the query from the Inertia page URL rather than `window.location` keeps it working with server-side rendering. A form built with `useForm()` instead of `
` adds `invitation` to its data rather than an input. ## Without Fortify Add the rule where your app validates registration: ```php use Tey\Concierge\Rules\MayRegister; Validator::make($input, [ 'email' => ['required', 'email', 'unique:users', new MayRegister], // ... ])->validate(); ``` The rule reads the token from the validated data, then from the current request, under `invitation`. To read it from somewhere else, pass it in: `new MayRegister($token)`. To decide without validating, call `Tey\Concierge\Registration\Registration`: | Method | Returns | | --- | --- | | `Registration::decide($email, $token = null)` | A decision with `allowed`, a `refusal` reason and `message()` | | `Registration::open()` | `true` only in `open` mode: whether a sign-in page should link to registration | | `Registration::closed()` | `true` in `closed` mode and in every misconfigured state | ## Messages | Refusal | Shown to the person | | --- | --- | | Closed or misconfigured | Registration is closed. | | No invitation, `invitation` mode | Registration is by invitation. Use the link in your invitation. | | Domain not allowed, no invitation | Registration is limited to approved email domains. Use your work email address, or the link in your invitation. | | Unknown, forged or already accepted token | That invitation link is not valid. Use the link in your invitation, or ask to be invited again. | | Expired | Your invitation has expired. Ask to be invited again. | | Revoked | Your invitation was revoked. Ask to be invited again. | | Another email | Register with the email address your invitation was sent to. | Every message goes through `__()`, so a JSON language file can override it. The allowed domains are never shown. ## What Your App Still Decides - Where a registered person lands. Registering creates the account; membership comes from accepting an invitation, or from your own code. In `domains` mode a matching person has an account and no workspace until they are invited or your app places them. - Whether the sign-in page links to registration: read `Registration::open()` in the view, or share it as an Inertia prop. - Captcha and rate limiting on the register route. ## Configuration Both `registration` keys, with their types and defaults, are in [Configuration](/reference/configuration#registration). --- # Users The Users pages list, invite and edit everyone in an app-wide app, and deactivation takes a person's access away without deleting anything. The pages and `UpdateUser` are for [app-wide mode](/features/app-wide-mode). Deactivation works in both modes. > [!TIP] > The Users pages and deactivation are early features. They are opt-in, and the user administration completes in 0.8 ([#43](https://github.com/teylabs/concierge/issues/43)). Until then, the API may change. Concierge ships no routes yet ([#28](https://github.com/teylabs/concierge/issues/28)), so your app supplies every link. ## The Users Pages Three [Vue pages](/features/vue-pages) ship in `@tey/concierge`. They never say "workspace". | Page | Props | What it shows | | --- | --- | --- | | `UsersPage` | `UsersPageProps` | Everyone, paginated, with search, a role filter and a status filter | | `UserPage` | `UserPageProps` | One user: edit their name, email and roles; deactivate, restore or remove them | | `InviteUserPage` | `InviteUserPageProps` | The app-wide invitation form | The props are PHP classes in `Tey\Concierge\Data\UI`, with matching types in the generated `types.ts`. `AcceptInvitationPage` works for app-wide invitations too: with no workspace it says "Join" and your app's name, from `metadata.appName`, or plain "Join" when you leave it out. ### Links Come from Your App Concierge ships no routes yet, so your controllers build the props and supply every URL. A `null` link hides its control. Wire the pages to the actions: | Page control | Link or URL | Verb | Action | | --- | --- | --- | --- | | Search and filters | `UsersMetadata::$refreshUrl` | `GET` | Your index | | Invite | `UsersLinks::$invite`, then `InviteUserMetadata::$formUrl` | `POST` | `InviteUser` | | Open a user | `UserLinks::$show` | `GET` | Your show page | | Save name and email | `UserProfileMetadata::$formUrl` | `PUT` | `UpdateUser` | | Save roles | `UserGrantsMetadata::$formUrl` | `PUT` | `ChangeUserRoles` | | Deactivate | `UserLinks::$deactivate` | `POST`, with an optional `reason` | `DeactivateUser` | | Restore | `UserLinks::$restore` | `POST` | `RestoreUser` | | Remove | `UserLinks::$remove` | `DELETE` | `RemoveUser` | `UserPageProps::$profile` and `$grants` are nullable: pass `null` to hide that form. Deactivate shows only on an active user and Restore only on a deactivated one. The pages don't read `UserLinks::$updateProfile` or `$updateGrants`; they submit to each form's `formUrl`. Hidden controls are not authorization: every endpoint must still call the action, which checks its rules. ### The Status Filter `UsersMetadata::$status` is the filter the page shows: | Value | Shows | | --- | --- | | `null` or `'active'` | Active users only (the default) | | `'deactivated'` | Deactivated users only | | `'all'` | Everyone | The search visits `refreshUrl` with `search`, `role` and `status`, and leaves `status` out while it is `active`. Your controller decides which users to list, so filter on the account's `status` to match. Each `UserRow` carries its own `status`, `'active'` or `'deactivated'`. ### Your Own Columns Name your columns and return their values for each user: ```php // app/Providers/AppServiceProvider.php → boot() use Tey\Concierge\Facades\Concierge; Concierge::userColumns( ['plan' => 'Plan', 'seats' => 'Seats'], fn ($user) => ['plan' => $user->plan, 'seats' => $user->seats], ); ``` Then send the labels in the metadata and the values on each row: ```php new UsersMetadata(columns: Concierge::userColumnDefinitions(), /* ... */); new UserRow(columns: Concierge::userColumnValues($user), /* ... */); ``` Columns show after Joined. A key the callback leaves out renders empty, and a key it adds beyond the labels is not sent. Render a cell yourself with the `column` slot, which receives `column`, `user` and `value`: ```vue ``` ## Editing a User ```php // app/Http/Controllers/UserController.php → update() use Tey\Concierge\Actions\UpdateUser; UpdateUser::run($request->user(), $user, name: $request->input('name'), email: $request->input('email')); ``` `UpdateUser` edits another user's name and email in app-wide mode. A person's own profile stays with your app's profile page. - **Rules:** the same as removal. Not yourself, not someone of equal or higher rank, and your [account guard](/reference/actions-and-events#account-guard) may refuse. - **Fields:** a `null` argument leaves that field alone. Both are trimmed. The name is required, 255 characters at most. The email must be valid and not used by another user, ignoring case. Otherwise it throws a `ValidationException` keyed `name` or `email`. - **What it writes:** the model's `name` and `email` attributes, which your `conciergeDisplayName()` and `conciergeEmail()` read. Nothing is written when neither changes. - **Email verification** is your app's call: `email_verified_at` is not cleared and no verification email is sent. Listen to `UserUpdated`, which carries the old and new value of each changed field. ## Deactivating and Restoring ```php use Tey\Concierge\Actions\DeactivateUser; use Tey\Concierge\Actions\RestoreUser; DeactivateUser::run($request->user(), $user, reason: 'Left the company'); RestoreUser::run($request->user(), $user); ``` `DeactivateUser` takes a user's access away and keeps their history. `RestoreUser` gives it back. The state lives on the user's [account row](/features/app-wide-mode#the-accounts-table), not on your users table, so your own queries are untouched: filter on its `status` when you want to hide deactivated people. Roles and, in workspace mode, memberships stay as they are, so restoring brings back exactly what the user had, with no new invitation. The reason is optional, trimmed and cut to 500 characters. ### Who May Deactivate The same rules as removal apply, through the [`AccountPolicy`](/features/app-wide-mode#who-may-manage-whom): not yourself, not the last owner, not someone of equal or higher rank, and your account guard may refuse. Deactivating a deactivated user, or restoring an active one, is refused with the code `already_deactivated` or `not_deactivated`. Workspace mode has no app-wide roles, so bind your own `AccountPolicy` there to say who may deactivate. ### Signing In A listener on Laravel's `Illuminate\Auth\Events\Validated` event refuses a deactivated user once their credentials check out, before a session starts. It throws a `ValidationException` with "This account is deactivated." on the `email` key. This covers Fortify, the starter kits and any `Auth::attempt`. ### Sessions and Tokens When a user is deactivated: - their remember-me token is replaced; - with the `database` session driver, their rows in the sessions table are deleted; - API tokens are kept, so they work again after a restore. Add the `concierge.active` middleware after your authentication middleware to stop a deactivated user who is still signed in: ```php // routes/web.php Route::middleware(['auth', 'concierge.active'])->group(function () { // ... }); ``` On their next request it signs them out of every session guard and invalidates the session. A browser request is redirected to the `login` route with the message on `email`. A JSON request, a request with a bearer token, or an app without a `login` route gets a 403. Use it after token authentication too, for example `['auth:sanctum', 'concierge.active']`: the token request is refused and the token is left alone. With session drivers other than `database`, this middleware is what ends the session. In app-wide mode, the [`concierge.member`](/features/app-wide-mode#protecting-routes) middleware also responds 403 to a deactivated account. ### Events Listen to `UserDeactivated`, which carries the reason and the number of database sessions ended, and to `UserRestored`. See [Events](/reference/actions-and-events#events). --- # Forced Password Reset A forced password reset locks an account down when it may be compromised. A manager calls one action, and the person's password is replaced with one nobody knows, their remember-me token and [sessions](#sessions) end, two-factor is cleared when asked, and they receive a password reset link. It is part of [membership](/features/membership). ```php // app/Http/Controllers/MemberController.php → forcePasswordReset() use Tey\Concierge\Actions\ForcePasswordReset; ForcePasswordReset::run($request->user(), $membership, resetTwoFactor: true); ``` ## Who May Call It The target is a `Membership`, because a manager's authority comes from a workspace. The same rules apply as for suspending or removing a member: - The actor is an active manager of the membership's workspace and outranks the member. - Not on their own membership, and not on the workspace owner's. - Not on a removed membership. - When `recent_authentication_seconds` is set, the actor confirmed their password within it. - Your app's [account guard](#protecting-accounts) allows it. Each failure is an `AuthorizationException` with a [code](/reference/actions-and-events#who-may-call-them), and nothing about the account changes. ## What It Does In one database transaction: 1. The password column becomes the hash of a random 64-character string that is never stored or returned. No known password is ever set. 2. The remember-me token is replaced. 3. With the `database` session driver, the person's rows in the sessions table are deleted. 4. With `resetTwoFactor: true`, the two-factor columns in [`forced_password_reset.two_factor_columns`](/reference/configuration#forced-password-reset) that exist on the user table are set to `null`. Fortify's three are the default. 5. A token is created on the password broker and sent with the user's own `sendPasswordResetNotification()`, so `ResetPassword::createUrlUsing()` and your own notification apply. The broker's throttle does not apply: this is an admin action. After the transaction commits, it dispatches `PasswordResetForced`. The action returns a `ForcedPasswordReset` with `$membership`, `$twoFactorReset` and `$sessionsEnded`. ## Sessions Only the `database` session driver can list a person's sessions, so only there does concierge delete them, and `sessionsEnded` is the number of rows deleted. With any other driver, `sessionsEnded` is `null`: add Laravel's `AuthenticateSession` middleware to your authenticated routes, and it signs out any session whose stored password hash no longer matches. API tokens (Sanctum, Passport) are not touched. Listen to `PasswordResetForced` to revoke them. ## Protecting Accounts `Tey\Concierge\Contracts\AccountGuard` lets your app veto a forced reset for particular accounts, such as a platform operator's. It is checked after the workspace rules pass. The default allows everything. Bind your own in a service provider: ```php // app/Providers/AppServiceProvider.php → register() use Tey\Concierge\Contracts\AccountGuard; $this->app->bind(AccountGuard::class, OperatorAccountGuard::class); ``` ```php // app/Auth/OperatorAccountGuard.php conciergeEmail() === config('app.operator_email') ? Response::deny('The operator account is protected.', 'operator_protected') : Response::allow(); } } ``` `$operation` is `AccountGuard::FORCE_PASSWORD_RESET` for this action. ## Requirements - A user model that is `Authenticatable` and `CanResetPassword`, as Laravel's default `User` is. - Laravel's password broker. Set `forced_password_reset.broker` to use a broker other than the default. Otherwise the action throws `UnsupportedConfiguration` and nothing is written. Asking for `resetTwoFactor` when none of the configured columns exist also throws `UnsupportedConfiguration`, rather than reporting a reset that did not happen. ## What Your App Provides - The route, controller and policy that call the action, and the button in your UI. - The password reset page the link leads to. A starter kit already has one. - `AuthenticateSession` on your routes, unless sessions are stored in the database. ## Configuration Both `forced_password_reset` keys are in [Configuration](/reference/configuration#forced-password-reset). The action's full signature and event are in [Actions and Events](/reference/actions-and-events#forcepasswordreset). --- # Local Login Local login signs in as an account named in configuration with one click, and creates the account on the first click. It is for local development, where typing a password into a throwaway account is friction. It is off by default and works without [membership](/features/membership). ```php // config/concierge.php 'local_login' => [ 'enabled' => true, 'account' => ['email' => 'dev@example.test', 'name' => 'Local developer'], ], ``` Then render the button on your login page ([below](#showing-the-button)). ## When It Is Available Local login is available only when all of these hold, checked on every request: | Check | Must be | Read from | | --- | --- | --- | | Laravel's environment | exactly `local` | the configuration cache if there is one, otherwise `.env` or the process | | `APP_ENV` of the process serving the request | exactly `local` | `getenv`, then `$_SERVER` only when the process has none; never `$_ENV` or a cache | | `concierge.local_login.enabled` | `true` | configuration | | `concierge.local_login.account.email` | not empty | configuration | Exact means exact: `Local`, ` local ` and an empty value all fail. When any check fails there is no offer, and `POST /local-login` responds 404 before the session starts and before CSRF verification, the guest check and the throttle, even when the route came from a cache built in `local`. The route is registered only when both environments are `local`. This holds under php-fpm, `php artisan serve`, long-running workers such as Octane (the check runs per request, not per boot) and the console. ### Cached Configuration With `config:cache` or `optimize`, Laravel stops reading `.env`, so a local configuration cache alone is not enough. Set `APP_ENV=local` in the environment of the process that serves requests: ```bash php artisan config:cache APP_ENV=local php artisan serve # without APP_ENV here, local login is off ``` For php-fpm, set `env[APP_ENV] = local` in the pool. A non-local process value always wins over `$_SERVER`, and Concierge never changes the application's environment. ### When the Button Doesn't Appear If the login page shows only the email and password form, check which `APP_ENV` the serving process has. Laravel's environment and the process's `APP_ENV` must both be exactly `local`. The usual cause is a configuration cache. After `php artisan config:cache` or `php artisan optimize`, Laravel reads its environment from the cache and stops loading `.env`. `php artisan about` still says `local`, but the process serving requests has no `APP_ENV`, so local login stays off. Either clear the cache locally: ```bash php artisan config:clear ``` or give the process its own `APP_ENV`: - **`php artisan serve`**: start it as `APP_ENV=local php artisan serve`. Without a configuration cache, `serve` passes the values from `.env` to the server process, so `APP_ENV=local` in `.env` is enough. - **Valet**: set it for the site in a `.valet-env.php` file at the project root, which Valet passes to PHP-FPM. - **Herd, Valet and any php-fpm setup**: PHP-FPM reads `.env` on each request while the configuration isn't cached, so `APP_ENV=local` in `.env` is enough. With a cache, set `env[APP_ENV] = local` in the site's FPM pool, or clear the cache. An `APP_ENV` exported in your shell or set by your process manager also counts, and it wins over `.env`. `APP_ENV=development php artisan serve` turns local login off, for example. This is deliberate. A production server that cached its configuration while `APP_ENV=local` was set, or whose configuration was changed at runtime, still can't offer one-click sign-in unless the process actually serving requests says `local`. ## What It Does - It adds `POST /local-login` (route `concierge.local-login.store`), with CSRF protection, the `guest` middleware for the configured guard and a per-IP throttle (`local_login.throttle`, default 10 a minute). - The identity comes from configuration only; anything the browser sends is ignored. - A new account gets a random password nobody knows, a verified address if the table has `email_verified_at`, and a name if it has `name`. `account.attributes` can fill other required columns, but never the identity or password. - An existing account is signed in exactly as it is: nothing is saved, and its password, name, verification and roles are untouched. A soft-deleted match is not restored, and sign-in fails. - It bypasses the password and two-factor for that account, and nothing else: it grants no roles, memberships or workspace, and it does not mark the session as recently authenticated. - It signs in on `local_login.guard` (default: the application's default guard), which must use an Eloquent user provider. - After signing in it redirects to the intended URL, then `local_login.redirect`, then `/`. - No remember token is set, and the session ID and CSRF token are regenerated. ## Showing the Button Your app decides where the button appears. Pass the offer to your login page; it is `null` unless local login is available right now: ```php use Tey\Concierge\LocalAuth\LocalLogin; 'localLogin' => app(LocalLogin::class)->offer(), ``` Then render `LocalLoginButton` from the [Vue pages](/features/vue-pages#local-login-button). It renders nothing when the offer is `null`. ## Without Membership An app that wants only local login, with no workspaces or permission tables, turns membership off: ```php // config/concierge.php 'features' => ['membership' => false], ``` With that set, Concierge registers no membership bindings, no `concierge.member` middleware alias and no `concierge:sync-roles` command, and does not load its migration, so `php artisan migrate` needs no workspace or permission setup. The switch is read while the package provider registers, so set it in `config/concierge.php`, not at runtime. ## Configuration Every `local_login` key, with its type and default, is in [Configuration](/reference/configuration#local-login). --- # Vue Pages Concierge includes optional Vue 3 pages for managing members, users and invitations, plus a local-login button that works without them. They ship inside the Composer package, in `vendor/tey/concierge/resources/js`. The PHP code never depends on them or on Inertia. | Export | From | What it is | | --- | --- | --- | | `MembersPage`, `InviteMemberPage`, `MemberPage` | `@tey/concierge` | Workspace pages; each takes the matching generated `*PageProps` type as its props | | `UsersPage`, `InviteUserPage`, `UserPage` | `@tey/concierge` | App-wide pages, for [app-wide mode](/features/app-wide-mode); see [Users](/features/users) | | `AcceptInvitationPage` | `@tey/concierge` | Accepting an invitation, in either mode | | `createConcierge({ adapter, labels?, locale? })` | `@tey/concierge` | The Vue plugin every page requires | | `LocalLoginButton` | `@tey/concierge` | A plain form for one-click [local login](/features/local-login); needs no plugin and no Inertia | | `Avatar` | `@tey/concierge` | A person's photo, or their initials on a stable colour; see [Avatars](/features/avatars) | | `ConciergeAdapter`, `defaultLabels` and the generated contract types | `@tey/concierge` | Types for your own code | | `createInertiaAdapter()` | `@tey/concierge/inertia` | The adapter for Inertia; the only module that imports `@inertiajs/vue3` | Your app installs the peer dependencies: `vue ^3.5`, `tailwindcss ^4.1` and, for the Inertia adapter, `@inertiajs/vue3 ^3`. ## Adding the Pages to Your App Concierge declares its frontend to [mod](https://mod.teylabs.com), which Composer installs with it (`tey/mod` ^0.6.1). In a Vue starter kit app, mod wires it in for you. ### 1. Wire the Frontend with Mod Preview the edits first. Nothing is written: ```bash php artisan mod:install inertia --dry-run ``` Then apply them: ```bash php artisan mod:install inertia ``` This adds: - the `@tey/concierge` alias to `resolve.alias` in `vite.config.ts`, pointing at `vendor/tey/concierge/resources/js`; - the matching `compilerOptions.paths` in `tsconfig.json`, for both `@tey/concierge` and `@tey/concierge/*`, and the package sources to `include`; - `@source '../../vendor/tey/concierge/resources/js';` to `resources/css/app.css`, so Tailwind sees the classes the pages use. When mod can't recognize one of these files, it prints the line to add by hand instead of editing it. ### 2. Import the Stylesheet Mod does not import concierge's stylesheet yet ([#81](https://github.com/teylabs/concierge/issues/81)). It defines the `--concierge-*` variables the pages are coloured with. In your CSS entry, after `@import 'tailwindcss'`: ```css /* resources/css/app.css */ @import '../../vendor/tey/concierge/resources/css/concierge.css'; ``` Override any variable in an unlayered `:root` rule to restyle the pages, including the [avatar colours](/features/avatars#colours). ### 3. The Plugin Install it once, where your app creates the Vue app (in an Inertia app, `setup()` in `createInertiaApp`): ```ts // resources/js/app.ts import { createConcierge } from '@tey/concierge'; import { createInertiaAdapter } from '@tey/concierge/inertia'; app.use(createConcierge({ adapter: createInertiaAdapter() })); ``` ### 4. Wrapper Pages Your app keeps its layout, breadcrumbs and flash messages. Write the four workspace wrapper pages into your app with one command: ```bash php artisan concierge:pages --dry-run php artisan concierge:pages ``` It writes `Members.vue`, `InviteMember.vue`, `Member.vue` and `AcceptInvitation.vue` to `resources/js/pages/Concierge`. The three member pages use the starter kit's `@/layouts/AppLayout.vue`. They are yours to edit; render them with `Inertia::render('Concierge/Members')` and so on. - `concierge:pages Admin` puts them in `resources/js/pages/Admin` instead, and `--in=` places them in a module of your app's mod layout. - A page that already exists stops the command and nothing is written. Pass `--skip-existing` to keep your copies, or `--force` to overwrite them. - `--json` prints the plan or the result as JSON. Mod lists the same recipe as `mod:concierge/pages`. The stubs are Vue only for now, and the Users pages have no stub yet. To write a wrapper by hand, pass the server props straight through and use slots for the surrounding chrome: ```vue ``` Every page has the slots `header`, `breadcrumbs` and `aside`. `MembersPage` and `UsersPage` also have `empty`, and `UsersPage` has `column` for [your own columns](/features/users#your-own-columns). The members list and the member header show each person's [avatar](/features/avatars). Build each `UserSummary` with `UserSummary::for($user)`, and set `Concierge::avatarUsing()` if your app has photos. ## Wiring by Hand Without mod's installer, or in an app it can't recognize, make the same edits yourself. Alias the Composer-installed sources in Vite, and dedupe the framework packages so vendor files use your app's copies: ```ts // vite.config.ts import path from 'node:path'; import { defineConfig } from 'vite'; export default defineConfig({ // ...your existing plugins resolve: { alias: { '@tey/concierge': path.resolve('vendor/tey/concierge/resources/js') }, dedupe: ['vue', '@inertiajs/vue3'], }, }); ``` Mirror the alias in `tsconfig.json` `paths`: `@tey/concierge` → `index.ts`, and `@tey/concierge/*` → `*`. Then import the stylesheet as in [step 2](#_2-import-the-stylesheet). It also registers the package sources with `@source`, so a hand-wired app needs no separate `@source` line. Install the [plugin](#_3-the-plugin) as above. ## How the Pages Talk to Your Server - The pages never build routes. They navigate and submit only to URLs from their props, and a `null` link hides the control. Your endpoints must still authorize every request: hidden UI is not security. - Verbs: `POST` to invite, suspend, reinstate, deactivate, restore, transfer ownership, resend, accept and log out; `PUT` for role and profile changes; `DELETE` to remove and revoke. - The members search visits `metadata.refreshUrl` with the query parameters `search` and `status`. The users search adds `role`, and leaves `status` out while it is `active`. Empty values are left out. - Validation errors keyed `name`, `email`, `roles` or `roles.N` show on their fields. Any other key, such as a policy denial, shows as a form-level alert. - Repeated submits are ignored while a request is pending. Retry resends the identical request, so the endpoint must be idempotent. - Ownership transfer requires typing the recipient's name. The server is expected to redirect through password confirmation. ## Local Login Button `LocalLoginButton` renders a native `` to the URL in the offer. It does not need `createConcierge` or Inertia, only the Vite alias, the TypeScript paths and the CSS import from steps 1 and 2. ```vue ``` On the server, pass the offer as the `localLogin` page prop ([Local login](/features/local-login#showing-the-button)). | Prop | Type | Purpose | | --- | --- | --- | | `offer` | `LocalLoginOffer \| null` | `{ url, email }`. The button renders nothing when it is `null` or missing | | `csrfToken` | `string`, required | The session's CSRF token: shared from the server with Inertia, or `csrf_token()` in Blade. Sent as the hidden `_token` field | | `label` | `string` | Defaults to `Sign in as ` | | `hint` | `string` | Defaults to a line saying it is for local development only and skips the password and two-factor | The form sends the token and nothing else: the server signs in as the account in its own configuration. While the request is in flight the button is disabled and shows a spinner. Errors, such as a soft-deleted account, come back in the session error bag under `local_login`; your page decides whether to show them. --- # Avatars Every person on concierge's pages has an avatar: your 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 such as Gravatar. ## Photos Tell concierge where your app keeps a person's photo, once, in a service provider: ```php // app/Providers/AppServiceProvider.php → boot() use Tey\Concierge\Facades\Concierge; Concierge::avatarUsing(fn ($user) => $user->profile_photo_url); ``` The callback receives your user model and returns a URL, or `null` for someone without a photo. Without a callback, everyone gets initials. ## Initials and Colour - The initials are the first letters of the first and last words of the name, so `Ana María López` is `AL`. A one-word name gives one letter, and a person with no name gets the first letter of their email. - Letters are whole characters, so accents, non-Latin scripts and emoji come through intact. - The colour is one of eight palette names (`slate`, `indigo`, `violet`, `rose`, `amber`, `emerald`, `teal`, `sky`), picked from the user's key. A person keeps the same colour on every page. ## In Page Props `UserSummary`, the person in every page's props, carries an `avatar`: ```ts type UserAvatar = { url: string | null; initials: string; colour: string }; ``` Build a summary from your user model with `UserSummary::for()`. It applies `avatarUsing()`: ```php use Tey\Concierge\Data\UI\UserSummary; $summary = UserSummary::for($user); ``` The constructor still takes `id`, `name` and `email`. Its fourth argument, the avatar, is optional and defaults to initials, so summaries your app already builds keep working. `Concierge::avatar($user)` returns the avatar on its own. ## The Avatar Component The [Vue pages](/features/vue-pages) show avatars in the members list and the member header. The `Avatar` component is exported for your own pages: ```vue ``` | Prop | Type | Purpose | | --- | --- | --- | | `avatar` | `UserAvatar`, required | The avatar from a `UserSummary` | | `size` | `'sm' \| 'md' \| 'lg'` | Defaults to `md` | | `label` | `string \| null` | Leave it out beside the person's name: the avatar is then hidden from screen readers. Pass the name when the avatar stands on its own | A photo that fails to load falls back to the initials. ## Colours Each palette name is a pair of CSS variables in `concierge.css`, a pale background and dark text: `--concierge-avatar-` and `--concierge-avatar--fg`. Override them in an unlayered `:root` rule to match your theme: ```css /* resources/css/app.css */ :root { --concierge-avatar-indigo: #e0e7ff; --concierge-avatar-indigo-fg: #312e81; } ``` --- # Configuration Every key in `config/concierge.php`, with its type, default and what it does, grouped by feature. `php artisan concierge:install` writes the file; see [Installation](/guide/installation). Only the two `registration` keys read environment variables, `CONCIERGE_REGISTRATION` and `CONCIERGE_REGISTRATION_DOMAINS`. Set every other value in the file itself. Local login also checks `APP_ENV` directly; see [When It Is Available](/features/local-login#when-it-is-available). ## Mode | Key | Type | Default | What it does | | --- | --- | --- | --- | | `setup` | `bool` or `null` | `null` | Whether concierge is set up. `null` means set up once the app has its own `config/concierge.php`. `false` keeps concierge unregistered while the file exists. Read while the package provider registers. | | `mode` | `'app'`, `'workspaces'` or `null` | `null` | Where invitations and roles apply. `'app'` is the whole app, with Spatie teams off and no workspace model ([App-Wide Mode](/features/app-wide-mode)). `'workspaces'` is one workspace at a time ([Membership](/features/membership)). `null` is `'app'`, except that while `models.workspace` is set it is `'workspaces'`: that transition rule is removed at 1.0. Any other value throws `UnsupportedConfiguration` at boot. | | `workspaces.engine` | `'concierge'`, `'kit'` or `null` | `'concierge'` | Read in workspace mode only. `'concierge'` keeps members in concierge's own tables, with Spatie teams on. `'kit'` reads a starter kit's teams and never writes them, with Spatie teams off ([Starter Kit Teams](/features/starter-kit-teams)). `null` is `'concierge'`. | Both describe your schema, so set them in `config/concierge.php`, never from the environment. `php artisan about` shows the mode and the engine. ## Features | Key | Type | Default | What it does | | --- | --- | --- | --- | | `features.membership` | `bool` | `true` | Turns [membership](/features/membership) on. Only `false` turns it off. When off, Concierge registers no membership bindings, no `concierge.member` or `concierge.active` middleware alias and no `concierge:sync-roles` command, and does not run its migrations. | | `features.tenant_invitations` | `bool` | `false` | Turns [tenant invitations](/features/tenant-invitations) on. Only `true` turns it on, and only while membership is on. When off, there is no tenant invitations migration, rate limiter or Gate definition. Workspace mode only: with `'mode' => 'app'` it fails at boot. | Both switches are read while the package provider registers, so set them in `config/concierge.php`, not at runtime. Set `features.tenant_invitations` before publishing migrations. ## Models | Key | Type | Default | What it does | | --- | --- | --- | --- | | `models.user` | `class-string` or `null` | `null` | Your user model. Required for membership. It must be an Eloquent model implementing `Tey\Concierge\Contracts\Member`, use Spatie's `HasRoles` and have an incrementing integer key. | | `models.workspace` | `class-string` or `null` | `null` | Your workspace model. Required in workspace mode, and left `null` in app-wide mode. It must be an Eloquent model implementing `Tey\Concierge\Contracts\Workspace`, with an incrementing integer key. | | `models.membership` | `class-string` | `Tey\Concierge\Models\Membership` | The membership model. Swap it for a subclass. | | `models.ownership` | `class-string` | `Tey\Concierge\Models\Ownership` | The ownership model. Swap it for a subclass. | | `models.invitation` | `class-string` | `Tey\Concierge\Models\Invitation` | The workspace invitation model. Swap it for a subclass. | | `models.tenant_invitation` | `class-string` | `Tey\Concierge\Models\TenantInvitation` | The tenant invitation model. Swap it for a subclass. | | `models.account` | `class-string` | `Tey\Concierge\Models\Account` | The [account](/features/app-wide-mode#the-accounts-table) model, one row per user in `concierge_accounts`. Swap it for a subclass. | The package migrations read `models.user` and, in workspace mode, `models.workspace` to find your tables. With a required one missing or invalid, Concierge throws `Tey\Concierge\Exceptions\UnsupportedConfiguration` naming the key. [Membership](/features/membership#setting-up-in-order) gives the setup order. ## Roles | Key | Type | Default | What it does | | --- | --- | --- | --- | | `guard` | `string` or `null` | `null` | The guard name of Concierge's Spatie roles, used when `concierge:sync-roles` creates them and when Concierge looks them up. `null` is `auth.defaults.guard`. | | `roles` | `array` | `owner`, `admin`, `presenter` | The roles, keyed by name, ordered highest rank first. A member outranks another when their highest role comes earlier in this list. | | `roles.*.label` | `string` | `'Owner'`, `'Admin'`, `'Presenter'` | A display name for your app's UI. Concierge itself does not read it. | | `roles.*.grantable_by` | `list` | see below | The roles whose holders may grant this role. The owner role is never grantable, whatever this says. | | `owner_role` | `string` | `'owner'` | The role a workspace's owner holds. `EstablishWorkspace` and `TransferOwnership` assign it. | | `transfer_demotes_to` | `string` | `'admin'` | The role the previous owner holds after `TransferOwnership`. | | `managers` | `list` | `['owner', 'admin']` | Roles whose holders may manage members: invite, resend and revoke invitations, change roles, suspend, reinstate and remove. | The default roles: ```php // config/concierge.php 'roles' => [ 'owner' => ['label' => 'Owner', 'grantable_by' => []], 'admin' => ['label' => 'Admin', 'grantable_by' => ['owner']], 'presenter' => ['label' => 'Presenter', 'grantable_by' => ['owner', 'admin']], ], ``` Each role is a global Spatie role (team `null`). In workspace mode every assignment is scoped to a workspace; in app-wide mode it applies to the whole app, and the owner role may be held by several owners. Run `php artisan concierge:sync-roles` after changing the list: it creates missing roles and deletes none. An action that assigns a role that does not exist throws `UnsupportedConfiguration`. The rules these keys feed are listed under [Who May Call Them](/reference/actions-and-events#who-may-call-them). ## Invitations | Key | Type | Default | What it does | | --- | --- | --- | --- | | `invitations.expires_after_days` | `int` | `7` | Days an invitation link works, counted from when it is created or resent. Applies to workspace and tenant invitations. | | `invitations.resend_cooldown_seconds` | `int` | `60` | Minimum seconds between creating or resending an invitation and resending it again. Applies to workspace and tenant invitations. | | `invitations.deliver` | `bool` | `true` | Whether invitation emails are sent. Set `false` to create invitations without emailing them. Emails also need an invitation URL: see [What Your App Provides](/features/membership#what-your-app-provides). | ## Tenant Invitations Read only when `features.tenant_invitations` is `true`. | Key | Type | Default | What it does | | --- | --- | --- | --- | | `tenant_invitations.ability` | `string` | `'concierge.manage-tenants'` | The Gate ability every changing tenant invitation action checks. Define it yourself, or let Concierge define it from `platform_owners`. | | `tenant_invitations.platform_owners` | `list` | `[]` | Emails allowed to manage tenant invitations, compared case-insensitively. The account's email must also be verified. Used only when your app does not define the ability itself. | | `tenant_invitations.allow_reinvite_after_accept` | `bool` | `false` | When `true`, an email whose invitation was already accepted can be invited again, and accepting creates an additional workspace. When `false`, `InviteTenant` fails validation with "That email already has a workspace." | | `tenant_invitations.throttle.inspect` | `int` | `30` | Requests per minute for the `concierge-tenant-invitation-inspect` limiter. | | `tenant_invitations.throttle.accept` | `int` | `10` | Requests per minute for the `concierge-tenant-invitation-accept` limiter. | | `tenant_invitations.throttle.invite` | `int` | `20` | Requests per minute for the `concierge-tenant-invitation-invite` limiter. | [Tenant Invitations](/features/tenant-invitations#what-your-app-provides) shows how to apply the limiters to your routes. ## Recent Authentication | Key | Type | Default | What it does | | --- | --- | --- | --- | | `recent_authentication_seconds` | `int` or `null` | `null` | How long after confirming their password the owner may call `TransferOwnership`. `null` is `auth.password_timeout`, or 10800 seconds (three hours) when that is not set. When it is set, `ForcePasswordReset` also requires the manager to have confirmed their password within it; when `null`, `ForcePasswordReset` does not check. | The default check reads the `auth.password_confirmed_at` session value that Laravel's password confirmation sets, for the signed-in user only. ## Registration | Key | Type | Default | What it does | | --- | --- | --- | --- | | `registration.mode` | `string` | `'open'`, from `CONCIERGE_REGISTRATION` | Who may [register](/features/registration): `open`, `domains`, `invitation` or `closed`. Any other value, including an empty one, counts as `closed`. | | `registration.domains` | `string` or `list` | `''`, from `CONCIERGE_REGISTRATION_DOMAINS` | The email domains allowed in `domains` mode, comma-separated or as an array. Compared case-insensitively; a subdomain matches only when listed. With none, `domains` mode counts as `closed`. | The mode is read on each request. With Fortify installed it takes effect with no app code; otherwise add the `MayRegister` rule ([Without Fortify](/features/registration#without-fortify)). ## Forced Password Reset | Key | Type | Default | What it does | | --- | --- | --- | --- | | `forced_password_reset.broker` | `string` or `null` | `null` | The password broker (`auth.passwords`) that issues the reset link. `null` is the default broker. | | `forced_password_reset.two_factor_columns` | `list` | `['two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at']` | The user columns set to `null` when a reset asks for two-factor to be reset. Only those on the user table are cleared; with none present, asking throws `UnsupportedConfiguration`. | [Forced Password Reset](/features/forced-password-reset) explains sessions and the account guard. ## Local Login | Key | Type | Default | What it does | | --- | --- | --- | --- | | `local_login.enabled` | `bool` | `false` | Turns [local login](/features/local-login) on. Only `true` turns it on, and only in the `local` environment. | | `local_login.account.email` | `string` or `null` | `null` | The identity value of the account to sign in as. Required: local login is unavailable while it is empty. | | `local_login.account.name` | `string` or `null` | `null` | The name for an account the click creates. `null` derives one from the email, so `dev@example.test` becomes `Dev`. | | `local_login.account.attributes` | `array` | `[]` | Extra columns for an account the click creates. The identity and password columns are ignored here. | | `local_login.identity` | `string` | `'email'` | The column the account is looked up and created by. | | `local_login.guard` | `string` or `null` | `null` | The guard to sign in on; it must use an Eloquent user provider. `null` is `auth.defaults.guard`. | | `local_login.redirect` | `string` or `null` | `null` | Where to go after signing in when there is no intended URL. `null` is `/`. | | `local_login.throttle` | `int` | `10` | Requests per minute per IP for `POST /local-login`. | --- # Actions and Events Every public action Concierge ships, what it takes, who may call it, what it returns and throws, and every event it dispatches. Your controllers, commands and jobs call the actions; your listeners handle the events. ```php // app/Http/Controllers/MemberController.php → destroy() use Tey\Concierge\Actions\RemoveMembership; RemoveMembership::run($request->user(), $membership); ``` Each action in `Tey\Concierge\Actions` is called with `::run(...)`, which resolves it from the container and passes the arguments to its `handle()` method. Each action that changes a membership, ownership or invitation runs in one database transaction: when it throws, nothing is written. ## Membership Actions ### EstablishWorkspace ```php EstablishWorkspace::run(Workspace $workspace, Member $owner): Membership ``` Makes `$owner` an active member and the owner of `$workspace`, and gives them the `owner_role`. - **Who may call it:** your own code. It checks no permission; call it when your app creates a workspace. - **Returns:** the owner's `Membership`. Calling it again for the same owner returns the existing membership and changes nothing. - **Throws:** `WorkspaceAlreadyEstablished` when the workspace already has a different owner. `UnsupportedConfiguration` when `permission.teams` is not `true` or the owner role does not exist. - **Dispatches:** `WorkspaceEstablished`. ### InviteMember ```php InviteMember::run(Member $actor, Workspace $workspace, string $email, GrantSet $grants): IssuedInvitation ``` Creates a pending invitation to `$workspace` for `$email` (trimmed and lowercased) with the roles in `$grants`, and queues its email when [delivery](/reference/configuration#invitations) is set up. - **Who may call it:** a manager who may grant every role in `$grants` (see [Who May Call Them](#who-may-call-them)). - **Returns:** `IssuedInvitation`, with `$invitation` and the one-time `$token`. Only the token's hash is stored. - **Throws:** `AuthorizationException` from the grant policy. `ValidationException` when the email is invalid, the person is already an active or suspended member, an unexpired invitation is already pending for that email, or a role is not in the database yet (run `concierge:sync-roles`). An expired pending invitation for the same email is marked expired and replaced. - **Dispatches:** `MemberInvited`. `$grants` is a `Tey\Concierge\Support\GrantSet`, built from role names: ```php use Tey\Concierge\Actions\InviteMember; use Tey\Concierge\Support\GrantSet; $issued = InviteMember::run($request->user(), $workspace, 'ada@example.com', new GrantSet(['presenter'])); ``` ### ResendInvitation ```php ResendInvitation::run(Member $actor, Invitation $invitation): IssuedInvitation ``` Rotates the token (the old link stops working), renews the expiry, captures the roles again under the resender's current authority, records the resender as the inviter, and queues the email. - **Who may call it:** a manager who may grant the invitation's roles. - **Returns:** `IssuedInvitation` with the new token. - **Throws:** `AuthorizationException` from the grant policy, or with code `cooldown` within `invitations.resend_cooldown_seconds` of the last send. `ValidationException` when the invitation is not pending or one of its roles no longer exists. - **Dispatches:** `InvitationResent`. ### RevokeInvitation ```php RevokeInvitation::run(Member $actor, Invitation $invitation): Invitation ``` Revokes an invitation so its link stops working. - **Who may call it:** a manager of the invitation's workspace. - **Returns:** the `Invitation`. Revoking a revoked invitation returns it unchanged. - **Throws:** `AuthorizationException` from the grant policy. `ValidationException` when the invitation was already accepted. - **Dispatches:** `InvitationRevoked`, unless it was already revoked. ### InspectInvitation ```php InspectInvitation::run(?Member $user, string $token): InvitationInspection ``` Reads an invitation's state for your acceptance page. It writes nothing, and `AcceptInvitation` checks everything again. - **Who may call it:** anyone holding the token, signed in or not (`$user` is `null` for a guest). - **Returns:** `InvitationInspection`, with `$state` (an [`InvitationState`](#invitation-states)) and `$invitation` (`null` when the token matches nothing). - **Throws:** nothing for an unknown token; the state is `Invalid`. ### AcceptInvitation ```php AcceptInvitation::run(Member $user, string $token): Acceptance ``` Makes `$user` an active member of the invitation's workspace with the invitation's roles. A removed member who accepts a new invitation becomes active again. In app-wide mode it grants the roles to the whole app instead, and records the invitation on the user's account row. - **Who may call it:** the signed-in invitee. Their email must match the invitation, and be verified. - **Returns:** `Acceptance`, with `$membership` (`null` in app-wide mode, where `$account` holds the account row) and `$outcome`: `AcceptanceOutcome::Joined`, or `AcceptanceOutcome::AlreadyMember` when the user was already an active member (their roles are not changed) or already accepted this invitation. - **Throws:** `InvitationRejected`, whose `$state` is the [`InvitationState`](#invitation-states) that stopped it. - **Dispatches:** `InvitationAccepted`, except when the same user accepts the same invitation again. `ResendInvitation` and `RevokeInvitation` are for workspace invitations only for now ([#79](https://github.com/teylabs/concierge/issues/79)). ### ChangeMemberRoles ```php ChangeMemberRoles::run(Member $actor, Membership $membership, GrantSet $grants): Membership ``` Replaces the member's roles in that workspace with the roles in `$grants`. - **Who may call it:** a manager who outranks the member and may grant every role in `$grants`. Not on their own membership or the owner's. - **Returns:** the `Membership`. - **Throws:** `AuthorizationException` from the grant policy, or with code `not_member` when the membership was removed. - **Dispatches:** `MemberRolesChanged`. ### SuspendMembership ```php SuspendMembership::run(Member $actor, Membership $membership, ?string $reason = null): Membership ``` Suspends the membership. The member keeps their roles but is no longer active. `$reason` is stored, cut to 500 characters. - **Who may call it:** a manager who outranks the member. Not on their own membership or the owner's. - **Returns:** the `Membership`. Suspending a suspended membership returns it unchanged. - **Throws:** `AuthorizationException` from the grant policy, or with code `not_member` when the membership was removed. - **Dispatches:** `MembershipSuspended`, unless it was already suspended. ### ReinstateMembership ```php ReinstateMembership::run(Member $actor, Membership $membership): Membership ``` Makes a suspended membership active again, with the roles it held. - **Who may call it:** a manager who outranks the member. Not on their own membership or the owner's. - **Returns:** the `Membership`. Reinstating an active membership returns it unchanged. - **Throws:** `AuthorizationException` from the grant policy, or with code `not_member` when the membership was removed: a removed member must be invited again. - **Dispatches:** `MembershipReinstated`, unless it was already active. ### RemoveMembership ```php RemoveMembership::run(Member $actor, Membership $membership): Membership ``` Ends the membership in that workspace, and clears the member's roles and direct permissions there. The user, their content and their roles in other workspaces are untouched. - **Who may call it:** a manager who outranks the member. Not on their own membership or the owner's. - **Returns:** the `Membership`. Removing a removed membership returns it unchanged. - **Throws:** `AuthorizationException` from the grant policy. - **Dispatches:** `MembershipRemoved`, unless it was already removed. ### TransferOwnership ```php TransferOwnership::run(Member $actor, Membership $recipient): Membership ``` Makes `$recipient` the workspace owner. The recipient gets the `owner_role`; the previous owner keeps their membership with the `transfer_demotes_to` role. - **Who may call it:** the workspace's active owner, who confirmed their password within `recent_authentication_seconds`. The grant policy is not consulted. - **Returns:** the recipient's `Membership`. - **Throws:** `AuthorizationException` with code `not_owner` when `$actor` is not the active owner, `recent_auth_required` when the password was not confirmed recently, or `recipient_invalid` when the recipient is not another active member of the same workspace with a verified email. - **Dispatches:** `OwnershipTransferred`. ### ForcePasswordReset ```php ForcePasswordReset::run(Member $actor, Membership $membership, bool $resetTwoFactor = false): ForcedPasswordReset ``` Locks the member's account down: replaces the password with the hash of a random value nobody knows, replaces the remember-me token, deletes the person's sessions when the session driver is `database`, clears the configured two-factor columns when `$resetTwoFactor` is `true`, and sends a reset link through the user's own `sendPasswordResetNotification()`. See [Forced Password Reset](/features/forced-password-reset). - **Who may call it:** a manager who outranks the member. Not on their own membership or the owner's. When `recent_authentication_seconds` is set, the actor must have confirmed their password within it. The [`AccountGuard`](#account-guard) must allow it. - **Returns:** `Tey\Concierge\Support\ForcedPasswordReset`, with `$membership`, `bool $twoFactorReset` and `?int $sessionsEnded` (database session rows deleted, or `null` with any other session driver). - **Throws:** `AuthorizationException` from the grant policy, with code `not_member` when the membership was removed, `recent_auth_required` when the password was not confirmed recently, or the denial your `AccountGuard` returns. `UnsupportedConfiguration` when the user model is not `Authenticatable` and `CanResetPassword`, the broker is not Laravel's, or `$resetTwoFactor` is `true` and none of `forced_password_reset.two_factor_columns` exist on the user table. - **Dispatches:** `PasswordResetForced`. ### SyncRoleDefinitions ```php SyncRoleDefinitions::run(): void ``` Creates each role in `roles` that does not exist yet, as a global Spatie role on the configured `guard`, then clears Spatie's permission cache. It deletes nothing. - **Who may call it:** your own code, or the console as `php artisan concierge:sync-roles` (registered while membership is on). - **Throws:** `UnsupportedConfiguration` when `permission.teams` is not `true`. ## App-Wide Actions These need `'mode' => 'app'`; see [App-Wide Mode](/features/app-wide-mode). In workspace mode each throws `UnsupportedConfiguration` naming the workspace action to use. They check the [account policy](#account-policy) rather than the grant policy, and each runs in one database transaction that locks the account rows of every owner, the actor and the user. ### EstablishOwner ```php EstablishOwner::run(Member $user): Account ``` Makes `$user` an active owner of the app: their roles are replaced with the `owner_role`, and their account row is created or made active. - **Who may call it:** your own code. It takes no actor and checks no permission. - **Returns:** the user's `Tey\Concierge\Models\Account`. Calling it again for an owner changes nothing. Several owners are allowed. - **Throws:** `UnsupportedConfiguration` in workspace mode, or when `permission.teams` is `true`. - **Dispatches:** `OwnerEstablished`, unless the user was already an owner. ### InviteUser ```php InviteUser::run(Member $actor, string $email, GrantSet $grants): IssuedInvitation ``` Creates a pending app-wide invitation for `$email` (trimmed and lowercased) with the roles in `$grants`, and queues its email when [delivery](/reference/configuration#invitations) is set up. Accept it with [`AcceptInvitation`](#acceptinvitation). - **Who may call it:** an active manager who may grant every role in `$grants`. - **Returns:** `IssuedInvitation`, with `$invitation` and the one-time `$token`. - **Throws:** `AuthorizationException` from the account policy. `ValidationException` when the email is invalid, a user with that email already exists ("change their roles instead", or "restore it instead" when deactivated), an unexpired invitation is already pending for it, or a role does not exist. An expired pending invitation for the same email is marked expired and replaced. - **Dispatches:** `UserInvited`. ### ChangeUserRoles ```php ChangeUserRoles::run(Member $actor, Member $user, GrantSet $grants): Account ``` Replaces the user's app-wide roles with the roles in `$grants`. Granting the owner role is how ownership passes on. - **Who may call it:** an active manager who outranks the user and may grant every role in `$grants`. An owner may manage another owner, and grant or take away the owner role. Not on themselves, and never leaving no owner. - **Returns:** the user's `Account`. - **Throws:** `AuthorizationException` from the account policy. - **Dispatches:** `UserRolesChanged`. ### RemoveUser ```php RemoveUser::run(Member $actor, Member $user): Member ``` Clears the user's roles and direct permissions, deletes their account row, then calls `delete()` on the user model. A model with `SoftDeletes` is soft deleted. Content the user wrote and your other tables are yours to handle; listen to `UserRemoved`. - **Who may call it:** the same as `ChangeUserRoles`, without the grant check. - **Returns:** the user model. - **Throws:** `AuthorizationException` from the account policy. `UnsupportedConfiguration` when the user model is not an Eloquent model. - **Dispatches:** `UserRemoved`. ### UpdateUser ```php UpdateUser::run(Member $actor, Member $user, ?string $name = null, ?string $email = null): Member ``` Writes another user's `name` and `email` attributes. A `null` argument leaves that field alone. See [Editing a User](/features/users#editing-a-user). - **Who may call it:** the same as `RemoveUser`. - **Returns:** the user model. Nothing is written when neither field changes. - **Throws:** `AuthorizationException` from the account policy. `ValidationException` keyed `name` when the name is empty or longer than 255 characters, or `email` when the email is invalid, longer than 255 characters or used by another user (ignoring case). `UnsupportedConfiguration` when the user model is not an Eloquent model. - **Dispatches:** `UserUpdated`, when a field changed. ## Account Actions These work in both modes; see [Deactivating and Restoring](/features/users#deactivating-and-restoring). They check the [account policy](#account-policy). In workspace mode, bind your own policy to say who may call them. ### DeactivateUser ```php DeactivateUser::run(Member $actor, Member $user, ?string $reason = null): Account ``` Marks the user's account row deactivated, replaces their remember-me token and, with the `database` session driver, deletes their sessions. Roles, memberships and API tokens are kept. `$reason` is trimmed and cut to 500 characters. - **Who may call it:** the same as `RemoveUser`: not themselves, not the last owner, not someone of equal or higher rank. - **Returns:** the user's `Account`. - **Throws:** `AuthorizationException` from the account policy, or with code `already_deactivated`. `UnsupportedConfiguration` when the user model is not `Authenticatable`. - **Dispatches:** `UserDeactivated`. ### RestoreUser ```php RestoreUser::run(Member $actor, Member $user): Account ``` Makes a deactivated account active again, with the roles it had. No invitation is sent. - **Who may call it:** the same as `DeactivateUser`. - **Returns:** the user's `Account`. - **Throws:** `AuthorizationException` from the account policy, or with code `not_deactivated`. - **Dispatches:** `UserRestored`. ## Tenant Invitation Actions These need `features.tenant_invitations` to be `true`. See [Tenant Invitations](/features/tenant-invitations). ### InviteTenant ```php InviteTenant::run(Member $actor, string $email): IssuedTenantInvitation ``` Creates a pending tenant invitation for `$email` (trimmed and lowercased) and queues its email when [delivery](/reference/configuration#invitations) is set up. - **Who may call it:** a platform owner (the `tenant_invitations.ability` Gate ability). - **Returns:** `IssuedTenantInvitation`, with `$invitation` and the one-time `$token`. - **Throws:** `AuthorizationException` when `$actor` is not a platform owner. `ValidationException` when the email is invalid, an unexpired invitation is already pending for it, or an invitation for it was already accepted and `tenant_invitations.allow_reinvite_after_accept` is `false`. An expired pending invitation for the same email is marked expired and replaced. - **Dispatches:** `TenantInvited`. ### ResendTenantInvitation ```php ResendTenantInvitation::run(Member $actor, TenantInvitation $invitation): IssuedTenantInvitation ``` Rotates the token (the old link stops working), renews the expiry and queues the email. The original inviter is kept. An invitation that lapsed without being accepted can be resent. - **Who may call it:** a platform owner. - **Returns:** `IssuedTenantInvitation` with the new token. - **Throws:** `AuthorizationException` when `$actor` is not a platform owner, or with code `cooldown` within `invitations.resend_cooldown_seconds` of the last send. `ValidationException` when the invitation is not pending: accepted, revoked, or replaced by a newer invitation for the same email. - **Dispatches:** `TenantInvitationResent`. ### RevokeTenantInvitation ```php RevokeTenantInvitation::run(Member $actor, TenantInvitation $invitation): TenantInvitation ``` Revokes a tenant invitation so its link stops working. - **Who may call it:** a platform owner. - **Returns:** the `TenantInvitation`. Revoking a revoked invitation returns it unchanged. - **Throws:** `AuthorizationException` when `$actor` is not a platform owner. `ValidationException` when the invitation was already accepted. - **Dispatches:** `TenantInvitationRevoked`, unless it was already revoked. ### InspectTenantInvitation ```php InspectTenantInvitation::run(?Member $user, string $token): TenantInvitationInspection ``` Reads a tenant invitation's state for your register or sign-in page. It writes nothing, and `AcceptTenantInvitation` checks everything again. - **Who may call it:** anyone holding the token, signed in or not. - **Returns:** `TenantInvitationInspection`, with `$state` (an [`InvitationState`](#invitation-states)) and `$invitation`, whose `email` is the address to lock your register form to (`null` when the token matches nothing). ### AcceptTenantInvitation ```php AcceptTenantInvitation::run(Member $user, string $token): TenantAcceptance ``` Creates a workspace through your `Concierge::provisionWorkspaceUsing()` callback and makes `$user` its owner through `EstablishWorkspace`. - **Who may call it:** the signed-in invitee. Their email must match the invitation, and be verified. - **Returns:** `TenantAcceptance`, with `$invitation`, `$workspace`, `$membership` and `$outcome`: `TenantAcceptanceOutcome::Provisioned`, or `TenantAcceptanceOutcome::AlreadyProvisioned` when the same user accepts again (nothing changes). - **Throws:** `InvitationRejected` with the [`InvitationState`](#invitation-states) that stopped it. `UnsupportedConfiguration` when no provisioner is set or it does not return a saved workspace model. `WorkspaceAlreadyEstablished` when the provisioner returns a workspace that already has another owner. Anything the provisioner throws. In every case the invitation stays pending. - **Dispatches:** `WorkspaceEstablished`, then `TenantInvitationAccepted`. ## Local Login Action ### ResolveLocalAccount ```php app(ResolveLocalAccount::class)(string $guard, string $identity, string $value, ?string $name, array $attributes): Authenticatable ``` In `Tey\Concierge\LocalAuth\Actions`. Finds the account whose `identity` column equals `value` through the guard's Eloquent user provider, or creates it the way [local login](/features/local-login#what-it-does) does. It is invokable rather than called with `::run()`. The `POST /local-login` route calls it with the `local_login` settings. - **Who may call it:** your own code. It does not check the environment or `local_login.enabled`; `Tey\Concierge\LocalAuth\LocalLogin::isAvailable()` does. - **Returns:** the existing account unchanged, or the new one. - **Throws:** `Tey\Concierge\LocalAuth\Exceptions\LocalLoginRefused` when the guard does not use an Eloquent user provider, or the matching account is soft-deleted. ## Who May Call Them The membership actions that manage other members ask the grant policy, `Tey\Concierge\Contracts\GrantPolicy`, before writing. The default policy reads `roles`, `managers` and the ranks from [configuration](/reference/configuration#roles). It denies with an `AuthorizationException` whose code is: | Code | When | | --- | --- | | `not_member` | The actor is not an active member of the workspace, or the membership belongs to another workspace | | `not_manager` | The actor holds none of the `managers` roles | | `self` | The actor is acting on their own membership | | `owner_protected` | The membership is the workspace owner's | | `rank` | The member's highest role is equal to or above the actor's | | `not_grantable` | A role is not grantable by any role the actor holds, or is the owner role | Bind your own class to `Tey\Concierge\Contracts\GrantPolicy` in a service provider to change these rules. The tenant invitation actions check the `tenant_invitations.ability` Gate ability instead; see [Authorization](/features/tenant-invitations#authorization). ### Account Policy The app-wide and account actions ask `Tey\Concierge\Contracts\AccountPolicy` instead. The default, `Tey\Concierge\Support\ConfiguredAccountPolicy`, reads the same configuration with the app-wide [ownership rules](/features/app-wide-mode#who-may-manage-whom), and denies with these codes: | Code | When | | --- | --- | | `not_member` | The actor's account is deactivated | | `not_manager` | The actor holds none of the `managers` roles | | `self` | The actor is acting on their own account | | `rank` | The user's highest role is equal to or above the actor's, and they are not both owners | | `last_owner` | No other active owner would remain | | `not_grantable` | A role is not grantable by any role the actor holds | Bind your own class to `AccountPolicy` in a service provider to change these rules. ### Account Guard `Tey\Concierge\Contracts\AccountGuard` is a veto your app can place on operations on particular accounts. `ForcePasswordReset` asks it with the operation `AccountGuard::FORCE_PASSWORD_RESET`, after the grant policy passes. The account policy asks it with `AccountGuard::MANAGE_ACCOUNT` before its rank rules, and an owner it shields is not counted as another owner for the last-owner rule: ```php public function authorize(string $operation, Member $actor, Member $target): Response; ``` The default, `Tey\Concierge\Support\OpenAccountGuard`, allows everything. Bind your own class to `AccountGuard` in a service provider to protect an account; [Protecting Accounts](/features/forced-password-reset#protecting-accounts) has an example. ## Invitation States `Tey\Concierge\Enums\InvitationState` is what the inspect actions return and what `InvitationRejected::$state` holds. | State | Meaning | Workspace | Tenant | | --- | --- | --- | --- | | `Ready` | The signed-in user can accept | ✓ | ✓ | | `RegisterRequired` | Valid and pending; no account exists for the invited email | ✓ | ✓ | | `SignInRequired` | Valid and pending; an account exists for the invited email | ✓ | ✓ | | `WrongAccount` | The signed-in user's email is not the invited email | ✓ | ✓ | | `Unverified` | The signed-in user's email is not verified | ✓ | ✓ | | `Expired` | The invitation lapsed | ✓ | ✓ | | `Revoked` | The invitation was revoked | ✓ | ✓ | | `Accepted` | The invitation was already accepted, by someone else or by a user who is no longer an active member | ✓ | ✓ | | `AlreadyMember` | The signed-in user already accepted this invitation; for a workspace invitation, also when they are already an active member | ✓ | ✓ | | `Invalid` | The token matches no invitation | ✓ | ✓ | | `Suspended` | The signed-in user's membership of the workspace is suspended | ✓ | | | `WorkspaceUnavailable` | The workspace's `conciergeAcceptsMembers()` returns `false` | ✓ | | | `AuthorityLapsed` | The inviter may no longer grant the invitation's roles | ✓ | | | `GrantsChanged` | A role was recreated or gained permissions since the invitation was sent | ✓ | | App-wide invitations use the workspace column. For them, `Suspended` means the signed-in user's account is deactivated, and `WorkspaceUnavailable` does not apply. `RegisterRequired` and `SignInRequired` are returned only to a guest. `AlreadyMember` for an active member is not a rejection: `AcceptInvitation` consumes the invitation and returns `AcceptanceOutcome::AlreadyMember`. ## Events Every event is in `Tey\Concierge\Events`, is dispatched only after the transaction commits, and carries ids, never models. A listener that needs a workspace's tenant context must enter it itself. `actorId` is the user who called the action. | Event | Dispatched when | Properties | | --- | --- | --- | | `WorkspaceEstablished` | `EstablishWorkspace` makes a user the owner of a workspace | `int $workspaceId`, `int $membershipId`, `int $userId` | | `MemberInvited` | `InviteMember` creates an invitation | `int $workspaceId`, `int $invitationId`, `?int $actorId` | | `InvitationResent` | `ResendInvitation` rotates a token | `int $workspaceId`, `int $invitationId`, `int $generation`, `?int $actorId` | | `InvitationRevoked` | `RevokeInvitation` revokes an invitation | `int $workspaceId`, `int $invitationId`, `?int $actorId` | | `InvitationAccepted` | `AcceptInvitation` consumes an invitation | `?int $workspaceId`, `int $invitationId`, `?int $membershipId` (both `null` for an app-wide invitation), `int $userId`, `string $outcome` (`joined` or `already_member`) | | `MemberRolesChanged` | `ChangeMemberRoles` replaces a member's roles | `int $workspaceId`, `int $membershipId`, `list $roles` (the new roles), `?int $actorId` | | `MembershipSuspended` | `SuspendMembership` suspends a membership | `int $workspaceId`, `int $membershipId`, `?int $actorId` | | `MembershipReinstated` | `ReinstateMembership` reinstates a membership | `int $workspaceId`, `int $membershipId`, `?int $actorId` | | `MembershipRemoved` | `RemoveMembership` removes a membership | `int $workspaceId`, `int $membershipId`, `?int $actorId` | | `OwnershipTransferred` | `TransferOwnership` changes the owner | `int $workspaceId`, `int $fromMembershipId`, `int $toMembershipId`, `?int $actorId` | | `PasswordResetForced` | `ForcePasswordReset` locks an account down | `int $workspaceId`, `int $membershipId`, `int $userId`, `?int $actorId`, `bool $twoFactorReset`, `?int $sessionsEnded` | | `OwnerEstablished` | `EstablishOwner` makes a user an owner | `int $userId` | | `UserInvited` | `InviteUser` creates an app-wide invitation | `int $invitationId`, `?int $actorId` | | `UserRolesChanged` | `ChangeUserRoles` replaces a user's roles | `int $userId`, `list $roles` (the new roles), `?int $actorId` | | `UserRemoved` | `RemoveUser` removes a user | `int $userId`, `?int $actorId` | | `UserUpdated` | `UpdateUser` changes a name or email | `int $userId`, `?int $actorId`, `array $changes`: each changed field (`name` or `email`) with its `old` and `new` value | | `UserDeactivated` | `DeactivateUser` deactivates an account | `int $userId`, `?int $actorId`, `?string $reason`, `?int $sessionsEnded` (database session rows deleted, or `null` with any other session driver) | | `UserRestored` | `RestoreUser` restores an account | `int $userId`, `?int $actorId` | | `TenantInvited` | `InviteTenant` creates a tenant invitation | `int $invitationId`, `?int $actorId` | | `TenantInvitationResent` | `ResendTenantInvitation` rotates a token | `int $invitationId`, `int $generation`, `?int $actorId` | | `TenantInvitationRevoked` | `RevokeTenantInvitation` revokes a tenant invitation | `int $invitationId`, `?int $actorId` | | `TenantInvitationAccepted` | `AcceptTenantInvitation` provisions a workspace | `int $invitationId`, `int $workspaceId`, `int $membershipId`, `int $userId`, `string $outcome` (`provisioned`) | `generation` counts sends: `1` for the first, plus one for each resend. ```php // app/Listeners/WelcomeNewMember.php outcome !== 'joined') { return; } // ...look up the membership by $event->membershipId } } ``` --- # What Concierge Covers Concierge is working toward 1.0 in public. This page is the plan: everything below is **planned and not yet released**, unless it is marked done. What ships today is in the [introduction](/guide/introduction). The canonical list is [ROADMAP.md](https://github.com/teylabs/concierge/blob/main/ROADMAP.md) in the repository; this page explains it. > [!NOTE] > This page describes plans, not features. Versions are milestones, not dates. Milestones and issues on GitHub are the source of truth: [teylabs/concierge milestones](https://github.com/teylabs/concierge/milestones). ## The End Goal Concierge is planned as a starter kit extension: it extends the Laravel starter kit an app started with, so the starter kit story carries on from sign-in to user management. Install a starter kit, then install concierge: ```bash composer require tey/concierge php artisan concierge:install ``` `concierge:install` is planned for 0.9. When the plan is complete, concierge works with all four official starter kits (Vue, React, Svelte and Livewire, including their WorkOS AuthKit variants), with or without their teams: - `concierge:install` detects the starter kit and scaffolds concierge's pages into the app, written with that kit's own components and layout ([#57](https://github.com/teylabs/concierge/issues/57)). The app owns the pages; the logic stays in the package and updates with `composer update`. - When the kit was generated with teams, concierge builds on the kit's own teams, memberships and invitations, and its pages extend the kit's team screens ([#20](https://github.com/teylabs/concierge/issues/20)). Without teams, invitations and roles apply to the whole app ([#19](https://github.com/teylabs/concierge/issues/19)). - The app's first owner signs in: one click in local development, created on deploy in production. - An app without a starter kit has an option too: concierge's packaged Vue components, a standalone portal at its own URL, or headless use with the app's own pages. [Issue #13](https://github.com/teylabs/concierge/issues/13) shows the same goal step by step: from a starter kit to working user management in two commands. ## Milestones ### 0.5: Ready for Everyone (Done) [Milestone 0.5](https://github.com/teylabs/concierge/milestone/1), closed: a newcomer can find, understand, install and trust concierge. - Local login fails closed outside the local environment, under every cache combination ([#9](https://github.com/teylabs/concierge/issues/9), done) - Published on Packagist ([#15](https://github.com/teylabs/concierge/issues/15), done) - The repository follows Tey Labs conventions: issue forms, Discussions, private vulnerability reporting ([#16](https://github.com/teylabs/concierge/issues/16), done) - A README rewritten for newcomers ([#10](https://github.com/teylabs/concierge/issues/10), done) - This documentation site ([#11](https://github.com/teylabs/concierge/issues/11), done) - A logo and wordmark ([#12](https://github.com/teylabs/concierge/issues/12), done) - Discoverability: llms.txt, Composer keywords, GitHub description and topics ([#14](https://github.com/teylabs/concierge/issues/14), done) ### 0.6: Essentials Beyond Membership (Done) [Milestone 0.6](https://github.com/teylabs/concierge/milestone/8), closed: registration gating, admin-forced password reset and initials avatars, on plain action and data classes. - [Registration gating](/features/registration): open, allowed email domains, invitation only or closed ([#45](https://github.com/teylabs/concierge/issues/45), done) - [Admin-forced password reset](/features/forced-password-reset): the action that locks an account down and sends a reset link (part of [#47](https://github.com/teylabs/concierge/issues/47), done; the user admin screens for it come in 0.8) - [Initials avatars](/features/avatars) for people without a photo ([#48](https://github.com/teylabs/concierge/issues/48), done) - Plain action and data classes in place of laravel-actions and laravel-data ([#51](https://github.com/teylabs/concierge/issues/51), done) ### 0.7: Built on Mod (Done) [Milestone 0.7](https://github.com/teylabs/concierge/milestone/2), closed: concierge's routes, pages and providers run through [mod](https://mod.teylabs.com). - Concierge declares its Inertia pages and frontend wiring to mod, so `php artisan mod:install inertia` sets up an app instead of hand-written aliases ([#17](https://github.com/teylabs/concierge/issues/17), done; see [Vue pages](/features/vue-pages)) - Require `tey/mod` ([#18](https://github.com/teylabs/concierge/issues/18), done) - `php artisan concierge:pages` writes concierge's pages into your app for you to own and edit ([#18](https://github.com/teylabs/concierge/issues/18), done) It also brings early, opt-in pieces of 0.8: [app-wide mode](/features/app-wide-mode), [starter kit teams](/features/starter-kit-teams), and the [Users pages and deactivation](/features/users). ### 0.8: Meets Any App Where It's At [Milestone 0.8](https://github.com/teylabs/concierge/milestone/3): with or without workspaces, with or without existing sign-in, plus the user administration every app needs. - App-wide mode: invitations and roles without workspaces ([#19](https://github.com/teylabs/concierge/issues/19), started in 0.7) - Use an app's existing team or organization model as the workspace, including a starter kit's own teams ([#20](https://github.com/teylabs/concierge/issues/20), done in 0.7) - Upgrade an app from app-wide mode to workspaces ([#21](https://github.com/teylabs/concierge/issues/21)) - Sign-in for apps that have no authentication ([#23](https://github.com/teylabs/concierge/issues/23)) - App-wide user admin: list, edit, change role, remove ([#43](https://github.com/teylabs/concierge/issues/43), started in 0.7) - Deactivate and restore users across the app ([#44](https://github.com/teylabs/concierge/issues/44), done in 0.7) - Impersonation with a visible "stop impersonating" banner ([#46](https://github.com/teylabs/concierge/issues/46)) - Admin-forced password reset in the user admin, building on the 0.6 action ([#47](https://github.com/teylabs/concierge/issues/47)) - A protected operator account, kept in sync on every deploy ([#49](https://github.com/teylabs/concierge/issues/49)) ### 0.9: Install and Go [Milestone 0.9](https://github.com/teylabs/concierge/milestone/4): one setup command handles the rest, for developers and their coding agents. - `concierge:install`: detect the app, ask only what it can't detect, set everything up ([#24](https://github.com/teylabs/concierge/issues/24)) - Register nothing until setup has run ([#25](https://github.com/teylabs/concierge/issues/25)) - Create the first owner in production, safely on every deploy ([#26](https://github.com/teylabs/concierge/issues/26)) - The first local sign-in creates the first owner ([#27](https://github.com/teylabs/concierge/issues/27)) - Routes, controllers and policies by default, each overridable ([#28](https://github.com/teylabs/concierge/issues/28)) - Every command works without prompts, offers `--json`, and names the fix when it fails ([#29](https://github.com/teylabs/concierge/issues/29)) - Laravel Boost guidelines and skill ([#30](https://github.com/teylabs/concierge/issues/30)) - MCP tools for setting up and operating concierge ([#31](https://github.com/teylabs/concierge/issues/31)) ### 0.10: Every Frontend [Milestone 0.10](https://github.com/teylabs/concierge/milestone/5): native pages scaffolded into each starter kit, packaged components, a standalone portal, or headless use. - Scaffold native pages for each starter kit, with options for apps without one ([#57](https://github.com/teylabs/concierge/issues/57)) - React starter kit pages, scaffolded with the kit's own components ([#40](https://github.com/teylabs/concierge/issues/40)) - Svelte starter kit pages, scaffolded with the kit's own components ([#58](https://github.com/teylabs/concierge/issues/58)) - Livewire starter kit pages, scaffolded with the kit's own components ([#41](https://github.com/teylabs/concierge/issues/41)) - A standalone portal served from a prebuilt bundle ([#32](https://github.com/teylabs/concierge/issues/32)) - A portal header with "return to app", the app's name and logo ([#33](https://github.com/teylabs/concierge/issues/33)) - A browser setup wizard, in the local environment only ([#34](https://github.com/teylabs/concierge/issues/34)) - Notification topics: choose who receives which alerts ([#50](https://github.com/teylabs/concierge/issues/50)) - A README hero: from a starter kit to working user management in two commands ([#13](https://github.com/teylabs/concierge/issues/13)) ### 1.0: A Stable Promise [Milestone 1.0](https://github.com/teylabs/concierge/milestone/6): a frozen PHP API and TypeScript contract, a support policy, complete docs and a release candidate. - Decide the public API and mark the rest internal ([#35](https://github.com/teylabs/concierge/issues/35)) - Freeze the TypeScript contract, proven by a second frontend ([#36](https://github.com/teylabs/concierge/issues/36)) - A versioning and support policy ([#37](https://github.com/teylabs/concierge/issues/37)) - Complete documentation and an upgrade guide ([#38](https://github.com/teylabs/concierge/issues/38)) - A release candidate ([#39](https://github.com/teylabs/concierge/issues/39)) ### After 1.0 [Milestone 1.x](https://github.com/teylabs/concierge/milestone/7), in no particular order: - UUID and ULID keys ([#22](https://github.com/teylabs/concierge/issues/22)) - Magic-link sign-in ([#42](https://github.com/teylabs/concierge/issues/42))