adhocrat-io/arkhe-main
Bootstrap a Laravel admin backend with Livewire 4 + Flux UI: users, roles & permissions (Spatie), user profiles, SEO + sitemap, and cookie-consent. Interactive installer publishes config/migrations, seeds default roles, and patches sidebar/Tailwind scanning.
Bootstrap a Laravel backend with users, roles and permissions management, served by Livewire 4 and Flux UI Free. Ships first-class SEO, sitemap and cookie-consent integrations on top.
First module of the
adhocrat-io/arkhe-*namespace.
| Component | Version |
|---|---|
| PHP | ^8.3 |
| Laravel | `^12.0 |
| Livewire | ^4.0 |
| Flux UI | ^2.1 (Free edition) |
| Spatie laravel-permission | ^7.0 |
| ralphjsmit/laravel-seo | ^1.8 |
| spatie/laravel-sitemap | ^8.1 |
| whitecube/laravel-cookie-consent | ^1.3 |
The installer expects a Livewire 4 + Flux UI host app. The smoothest experience is on Laravel 12 with the Livewire/Volt starter kit (or the Flux starter), because:
layouts::app (the Livewire 4 convention) resolves to a view the starter kit ships;<flux:sidebar.nav>;@source patch (step 9 below) targets the starter's resources/css/app.css.On a bare Laravel app with no starter kit the package still installs cleanly — the optional steps (sidebar + CSS) are skipped silently. You then have to point Arkhe at its own bundled layout (header-only, no sidebar):
ARKHE_ADMIN_LAYOUT=arkhe::layouts.app
See Limitations for the full list of skip conditions.
composer require adhocrat-io/arkhe-main
php artisan arkhe:main:install
The interactive installer walks through every step in order:
config/arkhe.php.first_name, last_name, avatar_path, phone, date_of_birth, civility, bio) to the users table.spatie/laravel-permission is not migrated yet, publish its config and migrations automatically — no need to run its setup separately.ralphjsmit/laravel-seo is not migrated yet, publish its migration + config (see SEO).php artisan migrate.root, administrateur, user, guest.<flux:sidebar.nav> with @include('arkhe::partials.sidebar-items') (idempotent — skipped if already done).resources/css/app.css with the @source directive needed to scan the package's Blade views (idempotent). For Tailwind v3 setups, the installer prints the equivalent content glob to add to tailwind.config.js.HasBackendProfile trait to your App\Models\User automatically (skipped if the model already uses HasRoles, which would conflict).Every step is idempotent — re-running arkhe:main:install after an upgrade is safe and is the recommended way to pick up new install-time integrations (see Upgrading).
Once installed, add new users without leaving the terminal:
# fully interactive
php artisan arkhe:main:add-user
# one-liner with explicit options
php artisan arkhe:main:add-user \
--email=ops@example.com \
--first=Ops \
--last=Team \
--role=administrateur \
--password=...
The command lets you pick the role from a prompt populated by the roles table. CLI calls bypass the runtime role-hierarchy check (the assumption being that anyone with shell access already has full authority), so you can seed a root user from a deploy script without auth context.
.env:
ARKHE_ROUTE_PREFIX=administration
ARKHE_AVATAR_DISK=public
ARKHE_AVATAR_PATH=avatars
The full configuration lives in config/arkhe.php after publishing.
Add the HasBackendProfile trait to your User model.
use Arkhe\Main\Concerns\HasBackendProfile;
class User extends Authenticatable
{
use HasBackendProfile; // ⚠ already pulls in Spatie's HasRoles — do NOT add `use HasRoles;` separately.
}
The trait adds three accessors (full_name, avatar_url, initials) and two helpers (isArkheRoot(), isArkheAdmin()).
By default: GET /administration/users (the prefix is configurable).
Access is granted to users carrying either the root or administrateur role; everyone else gets a 403 via the arkhe.backend middleware.
Arkhe is designed to plug into your existing admin shell rather than replace it. Two integration points:
In config/arkhe.php:
'layout' => 'components.layouts.app', // your starter kit layout
Arkhe pages will render inside your existing chrome (sidebar, topbar, your CSS).
Comment out the dashboard route in your routes/web.php and set the path:
ARKHE_DASHBOARD_ROUTE=administration/dashboard
Arkhe mounts a minimal users-by-role dashboard at the path you choose. Keep the env var unset and your own dashboard remains untouched.
Login redirect. The Laravel starter kits redirect to route('dashboard', absolute: false) after authentication. Two ways to point that at Arkhe:
A — re-use the dashboard route name (zero patch on your starter):
ARKHE_DASHBOARD_ROUTE=administration/dashboard
ARKHE_DASHBOARD_ROUTE_NAME=dashboard
route('dashboard') now resolves to /administration/dashboard, the after-login redirect just works.
B — keep arkhe.dashboard and patch the starter's login: open the login Livewire/Volt component and replace route('dashboard', absolute: false) with route('arkhe.dashboard', absolute: false). Pick this when another part of your app still needs dashboard to mean something else.
Fortify users (Laravel 12 Livewire starter kit included): the starter's login form posts to Fortify, which redirects to the literal value of
config('fortify.home')after auth — not via the nameddashboardroute. Arkhe detects Fortify automatically and rewrites that value to yourARKHE_DASHBOARD_ROUTEat boot, so neither A nor B is needed for the form submission to land on the right page. SetARKHE_OVERRIDE_FORTIFY_REDIRECT=falseto opt out.
Include the bundled partial at the top level of your <flux:sidebar.nav> — it emits its own Dashboard item plus the registry-driven groups ("Accès", "Réglages", and any group contributed by a satellite package), so it sits alongside your own groups rather than nested inside one:
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Platform')" class="grid">
{{-- your custom admin links --}}
<flux:sidebar.item icon="folder" :href="route('admin.projects.index')" wire:navigate>
Projects
</flux:sidebar.item>
</flux:sidebar.group>
{{-- Arkhe + satellite-package entries --}}
@include('arkhe::partials.sidebar-items')
</flux:sidebar.nav>
You can also publish the partial to customise it:
php artisan vendor:publish --tag=arkhe-views
# then edit resources/views/vendor/arkhe/partials/sidebar-items.blade.php
ArkheNavThe sidebar is driven by a navigation registry (Arkhe\Main\Support\ArkheNav). Arkhè seeds two sections — access ("Accès") and settings ("Réglages") — and any package can branch onto the same menu from its service provider's boot(), with no Blade patching. This is how adhocrat-io/arkhe-watcher and future packages plug in.
Add an entry to the shared Réglages section (one line per package — the goal: every package's settings in one place):
use Arkhe\Main\Support\ArkheNav;
ArkheNav::section('settings')->item(
key: 'billing',
label: fn () => __('billing::nav.title'), // closure → resolved at render (locale-aware)
icon: 'credit-card',
route: 'billing.settings',
active: 'billing.settings*', // routeIs pattern(s) for the "current" highlight
can: 'manage-billing', // permission string, closure(?$user): bool, or null
priority: 50, // ordering within the section
);
Or declare your own collapsible group (for a richer tool with several pages):
ArkheNav::section('reports', heading: fn () => __('reports::nav.title'), priority: 90, can: 'view-reports')
->item('sales', fn () => __('reports::nav.sales'), 'chart-bar', route: 'reports.sales')
->item('export', fn () => __('reports::nav.export'), 'arrow-down-tray', route: 'reports.export');
A section is rendered only when its gate passes and it has at least one visible item; items are filtered per-user by their own can. Sections and items order by priority (lower first). Because registration is keyed and idempotent, Main and packages can register in any order.
A menu
canonly hides the link — it is not access control. It governs visibility in the sidebar, nothing more. You must still protect the destination yourself by gating the package's routes with middleware (arkhe.backend,arkhe.root, or your own). Treat the menu gate and the route guard as two independent layers and keep them in sync. For example,adhocrat-io/arkhe-watchergates its routes witharkhe.watcher(andarkhe.rootfor its settings page) in addition to the matching menucan.
The Arkhe\Main\Support\RoleHierarchy helper encodes a configurable role hierarchy. A user can only assign roles whose rank is less than or equal to their own. The default order, highest first, is:
root > administrateur > user > guest
This means: only root can assign root; an administrateur cannot promote anyone to root; and so on.
The hierarchy is enforced at three layers:
<select> only lists roles the acting user can assign;UserForm rejects any out-of-rank role at validation time;UserService::syncRolesAndPermissions() throws AuthorizationException as a backstop for direct service callers.Two extension paths are available — pick the one that matches your situation.
config/arkhe.phpUse this when the roles are known at deploy time and live with the application: the values you want are part of the codebase, not contributed by an external package.
The order of config('arkhe.roles') IS the hierarchy (first entry = highest rank). Insert your role at the right position between two existing entries:
// config/arkhe.php
'roles' => [
'root' => 'root',
'administrator' => 'administrateur',
'manager' => 'manager', // new role, ranks between admin and user
'user' => 'user',
'guest' => 'guest',
],
Then create the matching row in the roles table by re-running the bundled seeder:
php artisan arkhe:main:install # answer No to publish + migrate, Yes is automatic on the seed step
# or, equivalently in a one-liner:
php artisan tinker --execute="app(\Arkhe\Main\Database\Seeders\ArkheRolesSeeder::class)->run();"
Pros: declarative, version-controlled, visible to every dev reading the config. Cons: requires editing the published file in each host app.
RoleHierarchy::register()Use this when the role is contributed by a package, module or feature flag — i.e. you cannot (or do not want to) require the host app to edit config/arkhe.php.
From your package's service provider:
use Arkhe\Main\Support\RoleHierarchy;
public function boot(): void
{
RoleHierarchy::register('manager', after: 'administrateur');
RoleHierarchy::register('editor', before: 'user');
RoleHierarchy::register('intern'); // append at the lowest rank
}
register() can also reposition an already-known role on subsequent calls. Your package is still responsible for creating the matching row in the roles table (typically via its own seeder).
Pros: zero config edit on the host side, perfect for distributed packages. Cons: invisible at first glance — document loudly which role(s) your package contributes.
| Scenario | Recommended |
|---|---|
| You own the app code end-to-end and the role belongs there | A — config |
The role ships in a Composer/Git submodule that you require from many apps |
B — register() |
| You want a flag to enable/disable a role per environment | B — register() inside an if (config('feature.x')) |
The four canonical Arkhe keys — root, administrator, user, guest — must remain in config('arkhe.roles'). Internal Arkhe code references them directly (config('arkhe.roles.root'), …). You can:
'user' => 'membre',Tailwind only compiles classes it can see. Since this package's Blade files live in vendor/adhocrat-io/arkhe-main/resources/views/, they are not scanned by the default Laravel setup. The installer takes care of this for Tailwind v4 (step 8 above); the snippets below document the same thing for reference or for manual setups.
Tailwind 4 (recommended, used by Flux 2):
/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
@source '../views';
@source '../../app/Livewire';
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
@source '../../vendor/adhocrat-io/arkhe-main/resources/views';
Tailwind 3:
// tailwind.config.js
export default {
content: [
'./resources/views/**/*.blade.php',
'./app/Livewire/**/*.php',
'./vendor/livewire/flux/stubs/**/*.blade.php',
'./vendor/adhocrat-io/arkhe-main/resources/views/**/*.blade.php',
],
// ...
}
Then:
pnpm build # or npm run build / yarn build
The layout published with the package uses @fluxAppearance and @fluxScripts from livewire/flux — make sure those directives are reachable (they ship with Flux automatically).
The package strictly follows the Repository + Service pattern:
Livewire Component
├──[read]──▶ UserRepository ──▶ Eloquent
└──[mutate]─▶ UserService ──▶ UserRepository ──▶ Eloquent
└──▶ Events (UserCreated / UserUpdated / UserDeleted)
No Eloquent query happens outside src/Repositories/. No mutation happens outside src/Services/. When extending, follow the same rule — see CONTRIBUTING for details.
| Event | Dispatched by | Payload |
|---|---|---|
Arkhe\Main\Events\UserCreated |
UserService::create() |
The fresh Model |
Arkhe\Main\Events\UserUpdated |
UserService::update() |
The fresh Model |
Arkhe\Main\Events\UserDeleted |
UserService::delete() |
The deleted Model |
Locale by default: fr (with en fallback). Override per-app via:
php artisan vendor:publish --tag=arkhe-translations
adhocrat-io/arkhe-main ships ralphjsmit/laravel-seo as a first-class dependency since 3.1.0. The arkhe:main:install command publishes the SEO package's migration and config, and the package's seo() helper is rendered in the Arkhe layout's <head>.
/administration/seoA root-only Livewire page at /administration/seo (named arkhe.site-seo.edit) edits the site SEO defaults stored in the arkhe_site_seo table:
<title>, e.g. | Acme)These values are merged into the SEOData rendered on every page via a SEOManager::SEODataTransformer registered by Arkhe\Main\ArkheMainServiceProvider::bootSeo(). They serve as fallbacks: anything provided per-page or per-model (see below) wins.
HasArkheSeo traitDrop the trait on any Eloquent model to get per-record SEO storage:
use Arkhe\Main\Concerns\HasArkheSeo;
class Post extends Model
{
use HasArkheSeo;
}
This creates a polymorphic seo row on every new Post, exposes a $post->seo relation, and lets you render:
{!! seo($post) !!}
You can also override SEO dynamically by implementing getDynamicSEOData() on your model (see the upstream package docs for the full API).
The merge order (highest priority first):
getDynamicSEOData() overrides on the resolved modelseo row ($model->seo)/administration/seoconfig('seo.php') (the upstream package's static defaults)Set arkhe.features.seo to false in your config/arkhe.php to skip the SEOData transformer registration. The seo() helper still works (the upstream package is always loaded), it just won't pick up Arkhe's site defaults.
Arkhe ships spatie/laravel-sitemap since 3.1.0. The package registers a scheduled GenerateSitemap job and exposes a root-only admin page at /administration/sitemap (route arkhe.sitemap.edit) to inspect status and trigger a regeneration on demand.
config/arkhe.php:
'sitemap' => [
'enabled' => env('ARKHE_SITEMAP_ENABLED', true),
'url' => env('ARKHE_SITEMAP_URL'), // null → falls back to config('app.url')
'path' => env('ARKHE_SITEMAP_PATH'), // null → falls back to public_path('sitemap.xml')
'schedule' => env('ARKHE_SITEMAP_SCHEDULE', '0 3 * * *'),
],
The cron expression is registered with callAfterResolving(Schedule::class, …) so it lights up as soon as the host app's scheduler runs. To skip the automatic scheduling without losing the admin button, set ARKHE_SITEMAP_ENABLED=false.
php artisan queue:work # if the queue isn't already running
# then click "Regenerate now" on /administration/sitemap
The Regenerate now button dispatches Arkhe\Main\Jobs\GenerateSitemap onto the host app's default queue. With the sync driver, the regeneration runs inline — same code path, no scheduler dependency.
Subclass Arkhe\Main\Services\SitemapService and override configureGenerator(SitemapGenerator $generator): void to add URLs, swap the crawl profile, or filter pages. Then point the binding at your subclass:
// AppServiceProvider::register
$this->app->bind(\Arkhe\Main\Services\SitemapService::class, \App\Services\MySitemapService::class);
For per-model integration, implement Spatie's Sitemapable contract on any Eloquent model — see the upstream docs.
Arkhe ships whitecube/laravel-cookie-consent since 3.1.0. The package's @cookieconsentscripts and @cookieconsentview blade directives are rendered in the Arkhe layout, gated by Features::hasCookieConsent() (defaults to true).
Arkhe\Main\Cookies\ArkheCookiesServiceProvider is registered automatically and declares Laravel's session + CSRF cookies under the essentials category. As soon as you install the package, the consent banner appears with a GDPR-compliant baseline — no extra setup required.
For cookies your app sets beyond the essentials (analytics, optional features, …), publish the upstream stub provider and register your own cookies in it:
php artisan vendor:publish --tag=laravel-cookie-consent-service-provider
php artisan vendor:publish --tag=laravel-cookie-consent-config
Then add App\Providers\CookiesServiceProvider::class to bootstrap/providers.php and edit it as documented upstream:
protected function registerCookies(): void
{
Cookies::analytics()->google(id: config('services.google_analytics.id'));
Cookies::optional()->name('darkmode')->duration(120);
}
Both providers coexist — Arkhe registers essentials, your provider adds the rest.
/administration/cookiesA root-only read-only Livewire page at /administration/cookies (route arkhe.cookies.index) lists every category and cookie currently registered through both Arkhe and any consumer-side providers. Use it as a GDPR audit trail.
Set arkhe.features.cookie_consent to false in config/arkhe.php to remove the banner directives from the Arkhe layout and skip registering Arkhe's essentials. The upstream package remains installed (Cookies::hasConsentFor(...) keeps working) — only the banner is silenced.
Both SEO and cookie consent became first-class features in 3.1.0 and default to on. The flags remain as escape hatches for consumers that want to keep the dependencies installed but silence the integration:
// config/arkhe.php
'features' => [
'seo' => true, // SEOData transformer, /administration/seo
'cookie_consent' => true, // Banner directives, /administration/cookies
],
Read them programmatically via \Arkhe\Main\Support\Features::hasSeo() / hasCookieConsent().
Seven layered ways to customise Arkhe without forking it — pick the lightest one that fits:
| # | Lever | Use when |
|---|---|---|
| 1 | Events — UserCreated, UserUpdated, UserDeleted (see Events) |
You need a side-effect (newsletter sync, audit log, webhook) that does NOT need access to the Livewire component state. |
| 2 | Lifecycle hooks on the Livewire pages — beforeSave(array): array, afterCreate(Model, array), afterUpdate(Model, array), beforeDelete(Model) |
The side-effect needs UI context — form payload, flash messages, redirects. Override in a subclass (see lever 3). |
| 3 | Rebindable Livewire components via config('arkhe.components') |
You want to subclass ListUsers / ListRoles / ListPermissions / Dashboard / SiteSeo / Sitemap / Cookies to add wire:click targets or extra fields. The route map auto-resolves to your class. |
| 4 | RoleHierarchy::register() (runtime) or config('arkhe.roles') (static) |
You ship a new role from a package or a host module — see Role hierarchy. |
| 5 | Custom permissions via config('arkhe.permissions') + config('arkhe.role_permissions'), re-seed with ArkheRolesSeeder |
You add domain permissions (manage-posts, publish-article, …) that should live next to Arkhe's bundled set. |
| 6 | ArkheNav navigation registry — add an item to the shared settings section or declare your own group (see Branch a package onto the shared menu) |
A package needs to contribute sidebar entries that show up in the common backend menu, gated by permission, with no Blade patching. |
| 7 | Publish the views (vendor:publish --tag=arkhe-views) |
The hooks / subclasses are not enough — you need a different Blade structure. Last resort; you take ownership of upgrade diffs. |
Subclass override example (lever 3 + lever 2):
// config/arkhe.php
'components' => [
'list-users' => App\Livewire\Admin\Users\AppListUsers::class,
],
// app/Livewire/Admin/Users/AppListUsers.php
class AppListUsers extends \Arkhe\Main\Livewire\ListUsers
{
protected function afterCreate(Model $user, array $payload): void
{
app(NewsletterService::class)->subscribe($user, 'admin');
}
public function resetPassword(int $id): void
{
// extra wire:click target — works because the route already resolves to this class
}
}
No route changes needed in the host app.
Things that may surprise you. None are blockers — most are deliberate trade-offs to keep the installer non-destructive.
| Area | Behaviour |
|---|---|
| Layout default | config('arkhe.admin.layout') defaults to layouts::app — a Livewire 4 convention served by the Livewire/Volt and Flux starter kits. On a bare app, set ARKHE_ADMIN_LAYOUT=arkhe::layouts.app to fall back on the package's bundled header-only layout, or point it at any view of your own. |
| Sidebar patch | Step 8 of arkhe:main:install only patches a file matching *sidebar*.blade.php that contains <flux:sidebar.nav>. No match → silently skipped (the bundled layout uses a <flux:header> dropdown, so a sidebar is not strictly required). If your app has multiple sidebar candidates, the installer refuses to choose and you must @include('arkhe::partials.sidebar-items') manually. |
| Tailwind v3 | Step 9 only auto-patches Tailwind v4 (@import "tailwindcss" in resources/css/app.css). Tailwind v3 setups get a printed snippet for tailwind.config.js — patching JS would be too brittle. |
| User model patch | Step 10 refuses to inject HasBackendProfile if the model already imports Spatie\Permission\Traits\HasRoles (it would conflict — HasBackendProfile already wraps HasRoles). Remove the explicit use HasRoles; first, or add use HasBackendProfile; by hand. |
/administration/dashboard |
Not registered by default — set ARKHE_DASHBOARD_ROUTE=administration/dashboard to opt in. Useful when you want Arkhe's users-by-role widget to replace the starter kit's empty /dashboard. |
| Layout chrome | The bundled arkhe::layouts.app ships with a Flux header (brand + profile dropdown) but no sidebar, navigation menu, or footer. It's deliberately minimal — to keep its real chrome, override the layout config. |
| Fortify redirect rewrite | When Fortify is detected and arkhe.dashboard_route is set, Arkhe rewrites config('fortify.home') at boot. Set ARKHE_OVERRIDE_FORTIFY_REDIRECT=false to opt out. |
spatie/laravel-permission cache |
The seeder calls Permission::create() directly. After re-running it (e.g. to add new permissions), clear the permission cache — php artisan permission:cache-reset — or restart your queue workers. |
Sitemap on sync queue |
The "Regenerate now" button dispatches GenerateSitemap onto the host app's default queue. With the sync driver it runs inline; with a real driver, make sure a worker is up — otherwise the page reports "queued" with no visible progress. |
composer update adhocrat-io/arkhe-main
php artisan arkhe:main:install # re-run, answer "no" to steps already done
arkhe:main:install is idempotent on every step (publish, migrate, seed, sidebar patch, css patch, trait patch). Re-running it after upgrading is the canonical way to pick up new install-time integrations (e.g. a new @source to add to app.css, a new sidebar entry to inject).
If you'd rather skip the prompts, the manual snippets in the Styling and Wiring up your User model sections give you the exact lines to add.
V3 keeps the V2 public surface — namespace Arkhe\Main, service provider, config prefix — so no global search-replace is required. A dedicated Artisan command handles the config migration:
composer update adhocrat-io/arkhe-main:^3.0
php artisan arkhe:main:upgrade-from-v2 --dry-run # preview the changes
php artisan arkhe:main:upgrade-from-v2 # apply
What it does:
config/arkhe.php (dashboard_route, role_permissions, components, backend_permission, root_permission, features) without touching existing V2 entries.resources/views/ (e.g. arkhe.main.livewire.admin.users.users-list → arkhe.list-users).See CHANGELOG.md for the full breaking-change list and the V2 → V3 migration playbook.
composer install
vendor/bin/pest
CI runs the matrix PHP 8.3/8.4 × Laravel 12.*/13.* × prefer-lowest/prefer-stable.
MIT — Luc, adhocrat.io.
How can I help you explore Laravel packages today?