ngos / admin-core
Reusable admin CRUD core (config-driven controllers/services, Route::crud macro, resource generator) for Laravel + Bootstrap 5, with a custom branded admin theme.
Requires
- php: ^8.3
- composer-runtime-api: ^2.2
- ext-dom: *
- bacon/bacon-qr-code: ^3.0
- guzzlehttp/guzzle: ^7.8
- illuminate/auth: ^13.0
- illuminate/cache: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/filesystem: ^13.0
- illuminate/http: ^13.0
- illuminate/notifications: ^13.0
- illuminate/process: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- illuminate/translation: ^13.0
- illuminate/validation: ^13.0
- illuminate/view: ^13.0
- intervention/image: ^3.0
- pragmarx/google2fa: ^8.0
- spatie/laravel-permission: ^8.0
- yajra/laravel-datatables-oracle: ^13.0
Requires (Dev)
Suggests
- laravel/passport: Required for `admin-core:install --api-auth` — OAuth2 (password grant) API auth for a decoupled front-end
This package is auto-updated.
Last update: 2026-06-24 16:33:32 UTC
README
Build a full Laravel admin panel fast. One command scaffolds a CRUD resource — model, migration, controller, form requests, Blade views, permissions and a searchable/sortable/exportable DataTable — on a clean, branded Bootstrap 5 theme.
- One generator.
admin-core:make Product→ a complete, permission-gated admin screen. - Batteries included (all opt-in flags): login + users/roles/permissions, CSV import/export, soft-deletes, audit log, error log, a JSON API, and a dynamic, permission-aware sidebar.
- Multi-portal. Stand up a second portal (merchant, vendor…) on its own auth guard in one command.
- Thin & conventional. Generated code lives in your
App\namespace and extends a small base (WebController+BaseService) — no magic, easy to read and edit.
Contents
- Quickstart — a working admin + your first resource
- Installation (minimal vs
--access) - Generating a resource → field types · add a field later
- What every list gets: export / import / bulk-delete · reorder · soft-deletes · audit · error log
- JSON API · API token auth
- Multi-portal — a separate-guard merchant/vendor area
- Notifications — in-app bell + notifications page
- UI & theme · Config & commands
Want the layer map?
ARCHITECTURE.md— web + JSON API over one shared service, plus a "where do I put X?" cheat sheet.
Quickstart
A working, authenticated admin in two steps.
1. Install (scaffolds login + users/roles/permissions + the theme, then builds assets and seeds an admin):
composer require ngos/admin-core php artisan admin-core:install --access --build --seed
Log in at /login with admin@example.com / password.
2. Generate your first resource:
php artisan admin-core:make Product --migration \
--fields="name:string, price:decimal, status:enum:draft|published"
php artisan migrate
Visit /admin/products — full CRUD with search, sort, status filter-tabs and CSV export, permissions
already granted to the admin role. That's the whole loop; everything below is detail.
No styling? The theme needs a front-end build. The
--buildflag above runs it; otherwise runnpm install && npm run buildyourself. (The admin still renders without it — just unstyled.)
Requirements
- PHP ^8.3, Laravel ^13
spatie/laravel-permission^8,yajra/laravel-datatables-oracle^13 (pulled in automatically)
Installation
There are two levels. Most people want --access (the Quickstart above) — a working, authenticated admin.
Minimal — just the CRUD engine
php artisan admin-core:install
Scaffolds only the glue generated pages need (idempotent; --force to overwrite). You bring your own auth:
| Published | Purpose |
|---|---|
config/admin-core.php |
route / view / permission / pagination / menu conventions |
config/class.php |
CSS-class map for tables/buttons/icons |
resources/views/backend/layouts/app.blade.php |
self-contained CDN starter layout |
resources/views/backend/dashboard.blade.php |
minimal dashboard so admin.dashboard resolves |
routes/Web/Backend/Modules/ |
auto-loaded folder for generated resource routes |
routes/web.php |
an admin route group + module loader (marked admin-core:routes) |
Full access module (--access) — login + users/roles/permissions
php artisan admin-core:install --access --build --seed # or drop --build/--seed to do them yourself
--accessships its owncreate_permission_tablesmigration (uuid + group_id aware) — don't alsovendor:publishSpatie's, ormigratefails with "table 'permissions' already exists". If you already did, the installer now removes the duplicate for you.
On top of the minimal install, --access adds (all in your App\ namespace, yours to edit):
- Auth — a session
LoginController+ login view +/login/logout; theadminroute group isauth-gated. - Users / Roles / Permissions screens built on the CRUD core, with role/permission assignment.
App\Models\Role/App\Models\Permission(extending spatie),HasRolesonApp\Models\User, the sidebar, and anAccessSeeder(anadminrole with every permission + theadmin@example.comuser).- The themed front-end kit (
--buildrunsnpm install && npm run build).
admin-core:make auto-grants each new resource's permissions to the admin role, so there's nothing to re-seed.
Keeping published assets in sync (admin-core:doctor)
The front-end kit (the JS behaviour in resources/js, the theme SCSS, the layout Blade) is copied out of
the package at install time — so those copies freeze, and a later package fix to, say, resources/js/datepicker.js
never reaches an app that installed an older version (stub drift). After upgrading the package, run:
php artisan admin-core:doctor # report what drifted / went missing (exits non-zero if any) php artisan admin-core:doctor --diff # …with a unified diff per file php artisan admin-core:doctor --fix # update them to the package version (review with `git diff` after)
Behaviour files (.js) are flagged distinctly — they're the ones that usually carry bug/security fixes. Your
own theme/layout edits live in these files too, so --fix is opt-in (and refuses non-interactively without
--force); review with git diff before committing, then rebuild assets.
Generating a resource
php artisan admin-core:make Product --migration
Generates the model, service, controller, form requests, a route module, the Blade views, and the
list/create/edit/delete-product permissions. Visit /admin/products.
Run it for a new resource without --fields and it prompts you for them interactively — enter a
name, pick a type from the menu, answer nullable/unique, repeat until you leave the name blank — then
generates from your answers. (You don't have to know the --fields DSL below to get started; pass
--fields to skip the prompts, and non-interactive runs — CI, scripts — just scaffold the default
name field.) Prefer to write the DSL by hand? php artisan admin-core:make --list-fields prints every
type and modifier it accepts.
Generating fields too (--fields)
Pass a field list and the generator fills in the migration columns, $fillable, validation rules,
form inputs, table headers, and DataTable columns — a ready-to-use CRUD, no manual edits:
php artisan admin-core:make Product --migration --fields="\ name:string, price:decimal?, description:text?, is_active:boolean, \ status:enum:draft|published, published_at:date?, category_id:foreign"
Field DSL — name:type, comma-separated:
| Type | Migration | Form control | Rule |
|---|---|---|---|
string (default) |
string |
text | string,max:255 |
text |
text |
textarea | string |
richtext |
text |
CKEditor WYSIWYG (sanitized on save, rendered on show) | string |
integer |
integer |
number | integer |
decimal |
decimal(10,2) |
number (step) | numeric |
boolean |
boolean default 0 |
checkbox | boolean |
date / datetime |
date / dateTime |
Air Datepicker (themed calendar / + time) | date |
time |
time |
native time | date_format:H:i |
email |
string |
email |
|
url |
string |
url | url,max:255 |
enum:a|b|c |
string |
<select> from cases |
Rule::enum (generated backed enum) |
slug |
string nullable unique |
text | alpha_dash + unique (auto from name) |
json |
json |
monospace textarea | array (decoded from the textarea) |
translatable |
json |
multi-locale inputs + auto-translate | array, default locale required |
password |
string |
password | min:8 (hashed; blank on edit = keep) |
foreign (x_id), foreign:table (self-ref/tree, e.g. parent_id:foreign:categories) |
foreignId()->constrained() |
Select2 of related rows | exists:table,id |
image |
string (path) |
file input + preview | image,max:2048 |
file |
string (path) |
file input | file,max:10240 |
belongsToMany (m2m) |
pivot table | multi-Select2 | array + exists |
The model also gets a casts() method (boolean, date, datetime, decimal:2, json → array,
password → hashed, enum → its backed enum class). A slug left blank is derived from name in the
creating hook; a json field round-trips through a textarea (decoded in prepareForValidation, stored
via the array cast); a blank password on update is dropped so the existing hash is preserved.
Date inputs use a themed calendar. date/datetime fields render as Air Datepicker
text inputs (--access bundles it) — a Bootstrap-themed calendar (with a time picker for datetime) that
matches your accent and flips with dark mode, instead of the unstyled native picker. The submitted value
keeps the Y-m-d / Y-m-d H:i shape the date rule and the model cast expect. The bundle auto-attaches it
to any .js-datepicker input on load; for a modal/AJAX-loaded form, call window.acInitDatepickers(formEl).
Enums are code, not schema. status:enum:draft|published generates App\Enums\ProductStatus (a
string-backed PHP enum) as the single source of truth: validation uses Rule::enum, the model casts
to it, and the form select, index filter-tabs and factory iterate its cases(). The DB column stays a
plain string — so adding a value is one new case in that file, no migration, and every layer picks
it up.
image/file also generate upload handling in the service (store on the public disk, delete the
old file on update, clean up on delete) and add enctype="multipart/form-data" to the form — run
php artisan storage:link once. belongsToMany generates the pivot migration, a belongsToMany
relation, a multi-select, and sync() in the service. Both infer the related model/table from the
field name, so generate the related resource first.
Modifiers (suffix, any order):
| Modifier | Meaning | What it generates |
|---|---|---|
? |
nullable | nullable column + nullable rule |
^ |
unique | unique index + unique rule (ignores self by route key on update) |
# |
index | plain (non-unique) DB index — ->index() on a hot filter/sort column (no-op if also ^, or on a foreign, since both already index) |
~ |
write-once | settable on create, locked on update — fillable + StoreRequest rule, no UpdateRequest rule, readonly input on edit |
@ |
system | set by trusted code only — not fillable, not validated, not in the form; a booted() hook scaffold + nullable column (shown read-only) |
E.g. slug:string^, published_at:date?# (nullable + indexed), status:enum:new|paid#, sku:string^~ (unique, locked after create).
Typed system helpers (imply @, auto-filled in the generated booted() hook — no TODO to wire up):
| Type | Column | Auto-set to |
|---|---|---|
created_by:auth |
nullable users FK |
auth()->id() |
code:sku |
nullable string | a generated Str::upper(Str::random(10)) code |
E.g. --fields="name:string, code:sku, created_by:auth" gives you an auto SKU and an owner stamp with zero hand-editing — neither is user-fillable.
Security note:
~and@enforce on the server (missing update rule / not fillable), not just the readonly input — so a user editing the DOM or POSTing directly still can't change them.
Foreign keys: category_id:foreign adds a belongsTo relation on the model, a Select2 dropdown of
the related rows in the form (labelled by the related row's name, falling back to id), and a
related-name column in the table. The related table is inferred (category_id → categories), so it
must already exist — generate the parent resource first.
App shell (with --access)
The --access kit now ships a complete admin shell beyond the access screens:
- Profile / account (
/admin/profile) — edit name/email, change password, upload an avatar. - Settings (
/admin/settings) — grouped key-value app settings with aSetting::get('key')helper (cached), gated by themanage-settingspermission. Seeded withapp_name,support_email, etc. - Dashboard — stat-card widgets (Users / Roles / Permissions / Group Permissions counts).
- Dynamic, permission-aware sidebar — the menu is a data array in
config('admin-core.menu'), rendered by<x-admin-core::sidebar-menu />. Each item is dropped automatically when the user lacks itscanpermission or itsroutedoesn't exist (so menus for un-installed features vanish on their own), and empty section headers are pruned.admin-core:makeappends the new resource there — no hand-editing Blade. (Older installs with the static sidebar still work: the generator falls back to injecting the link.) - Database-driven menu + Menu manager (optional) — manage the sidebar at runtime instead of editing
config. Set
config('admin-core.menu_source')to'database'(default'config') and the same<x-admin-core::sidebar-menu />renders themenu_itemstable — cached (MenuItem::tree(), busted on every write) and filtered by the same permission/route rules. The Menu manager at/admin/menu(System → Menu,manage-menu) lets admins add/edit/delete items and drag to reorder & nest them; each item is a label + icon + a named route or custom URL (or none → a section header) + optional permission + active toggle. Move your existing menu into the table withphp artisan admin-core:menu:import. Ships with--access. - Multi-portal — stand up a second portal (merchant, vendor, …) in one command:
php artisan admin-core:portal merchantscaffolds its own-guard user model + migration, login + dashboard, route group, and menu/permission config. Thenadmin-core:make Order --portal=merchantgenerates straight into it: routes underroutes/Merchant/Moduleswithmerchant.*names, permissions + gates on themerchantguard, and amenus.merchantentry rendered by<x-admin-core::sidebar-menu menu="merchant" guard="merchant" />(filtered against that portal's user). Single-guard apps just don't use it — nothing changes. See UPGRADING for details. - Show / detail view — every resource gets a read-only
showpage + a View button in the table.
Every list comes with export, import & bulk delete
Generated index screens ship these out of the box:
- Export — an
Exportbutton (a dropdown with a checkbox per field) streams the chosen columns to CSV (exportroute +?columns[]=, gated bylist-*; leave all checked for everything). Relations are included as readable columns — belongsTo as the related name (next to the FK) and belongsToMany as the related names joined (e.g.tags= "red, blue"). The output is injection-safe (formula cells are neutralised) and leads with a UTF-8 BOM so Excel reads it correctly. - Import — an
Importbutton opens a modal to upload a CSV (same shape as Export). The modal links a blank template (importTemplateroute) — a header-only CSV of the importable columns (fillable, minus password/file columns) so users don't have to guess the fields. Each row is validated against the resource's store rules; only fillable columns are kept (so a round-tripped export withid/uuid/timestamps imports cleanly), invalid rows are skipped and reported (importroute, gated bycreate-*). - Bulk delete — a select-all checkbox column + a "Delete selected" button that soft/hard-deletes the
chosen rows in one request (
bulkDeleteroute, gated bydelete-*).
All live on the base WebController (export() / import() / bulkDelete()), plus a single DataTables
search box (server-side via yajra), so they apply to every resource. Relation columns are searchable by the
related record's name out of the box (via whereHas): a belongsTo column is also sortable (a correlated
subquery), and a belongsToMany column is searchable but not sortable (sorting a multi-value relation is
ambiguous). Both assume the related model has a name column — the same assumption used to display it.
Create / update / delete (and restore) flash a success message that the layout renders automatically.
Customise or translate it by overriding one method on the generated controller:
protected function message(string $action): string { return __("products.{$action}"); // $action is created|updated|deleted|restored }
Drag-to-reorder (--sortable)
php artisan admin-core:make Category --sortable --migration --fields="name:string"
Adds a sort column and a Sort toggle button on the index that reveals a drag-and-drop panel
(reusing the bundled nestable plugin) — the DataTable stays put. Dragging a row posts the new order to a
reorder route, which persists each row's sort position via BaseService::reorder(). Best paired with
the --access kit (which bundles the nestable JS).
Audit trail (--audit)
php artisan admin-core:make Product --audit --migration --fields="name:string"
Adds the package's LogsActivity trait to the model, recording every create/update/delete in
activity_logs (the actor, the subject, and the changed attributes — sensitive fields like password
are filtered out). The activity_logs table migration is published by admin-core:install; the
--access kit adds a read-only Activity Log viewer (gated by list-activity). Set
'generator' => ['audit' => true] to audit every generated resource, or add the trait to any model:
use Ngos\AdminCore\Concerns\LogsActivity; class Order extends Model { use LogsActivity; }
Error log
The --access kit ships an Error Log: unhandled exceptions are written to an error_logs table
(type, message, file:line, stack trace, URL/method, user) and browsable at admin/error-logs
(gated by view-error-log) with a per-row detail view, delete, and "Clear all". Capture is wired by
a reportable callback the package registers on the framework's exception handler — no
bootstrap/app.php edit needed. It's deliberately quiet: expected exceptions (validation, auth,
404 and other 4xx HttpExceptions) are skipped, only genuine faults (5xx / uncaught) are recorded,
and capture is fully defensive — if the table is missing or anything throws while logging, it no-ops
rather than masking the original error. The error_logs migration is published by
admin-core:install --access.
The log self-trims: rows older than config('admin-core.error_log.retention_days') (default 30) are
pruned by a daily model:prune the package schedules for you (needs the app's scheduler cron running). Set
it to 0 to keep errors forever, or prune on demand with
php artisan model:prune --model="Ngos\AdminCore\Models\ErrorLog".
Soft deletes & extras
Every admin-core:make also generates a Factory (field-aware fake data), a Seeder, and a
permission-mapped Policy. Add --soft-deletes for a trash workflow:
php artisan admin-core:make Product --soft-deletes --migration --fields="name:string, price:decimal?"
It adds the SoftDeletes trait + deleted_at column, a Trash button on the index, and a
trash screen with Restore / Delete permanently (routes trash / restore / forceDelete,
backed by trashedQuery() / restore() / forceDelete() on the base service). "Delete permanently"
is a true hard delete; resources generated without soft deletes hard-delete on the normal delete.
To make every generated resource soft-delete by default, set
'generator' => ['soft_deletes' => true] in config/admin-core.php (override per-resource with
--no-soft-deletes for high-churn tables like sale lines or ledger rows that should hard-delete).
Generated tests (--tests)
php artisan admin-core:make Product --tests --migration --fields="name:string, price:decimal?"
Writes a self-contained tests/Feature/ProductTest.php that drives the resource over HTTP: the index +
getData render, store persists (faking any image/file uploads), update + delete resolve by the
public route key, and the index is forbidden without permission. It creates its own user and grants
the resource's permissions (via config('admin-core.permission.model')), so it runs green out of the box
— pair it with --migration so RefreshDatabase has the table.
JSON API (--api)
For a decoupled front-end (Nuxt, mobile, another SPA) or a multi-tenant merchant portal, --api adds a
clean JSON API alongside the Blade admin:
php artisan admin-core:make Product --api --migration --fields="name:string, price:decimal"
Generates a ProductResource (JsonResource), a Api\ProductApiController (index/show/store/
update/destroy), and a apiResource route file under api.products.* — Sanctum-gated, with each
action carrying the same permission as the web admin (list/create/edit/delete-product), so the
API and the back office enforce one permission model.
Channels are independent — pick what you need, add the rest later:
php artisan admin-core:make Product … # web only (default) php artisan admin-core:make Product … --api # web + API php artisan admin-core:make Product … --api-only # API only (headless: no views/web routes/sidebar)
Re-running is additive (existing files are skipped): a web-only resource gains the API by re-running with
--api; an api-only resource gains the web channel by re-running without --api-only. When you add a
channel to a resource that already exists, you can omit --fields entirely — they're reconstructed
from the existing model + migration (types and all), so adding the API to ten web resources is just:
for name in Post Product Order Customer …; do php artisan admin-core:make "$name" --api # fields inferred — no retyping done
(Upload image/file columns can't be told apart from plain strings when inferring — pass --fields
explicitly for those.) Both channels share the same model/service/requests, so nothing is duplicated. The controller reuses the same Service +
FormRequests as the web CRUD, so validation/authorization live in one place; the index is paginated
(?per_page=). Crucially, the public id is always the uuid route key,
never the bigint id — so internal ids are never enumerable across tenants:
{ "data": [ { "id": "019eb7a1-…-c046e429998b", "name": "Espresso", "price": "4.50" } ], "meta": { … } }
List query — the index supports ?search=, ?sort=, ?filter[col]= and ?per_page=, so a
front-end data table works out of the box:
GET /api/products?search=esp&filter[status]=active&sort=-created_at&per_page=20
The generated controller derives the whitelists from the fields — $searchable (text columns,
LIKE), $sortable (scalar columns + created_at; -col = desc), $filterable (enum/foreign/boolean,
exact match). Anything not on a whitelist is silently ignored, so a client can't sort/filter by an
arbitrary column. per_page is clamped to config('admin-core.api.max_per_page') (default 100).
Configure the guard + page size in config('admin-core.api') (default ['auth:sanctum'], 25) — add a
tenant-scoping middleware there for multi-tenant setups. API route files are auto-loaded if routes/api.php
globs routes/Api/Modules/*.php:
foreach (glob(__DIR__ . '/Api/Modules/*.php') ?: [] as $module) { require $module; }
API auth — token login (admin-core:install --api-auth)
Your --api resources are guarded, but the SPA/mobile client needs a way to log in and get a token.
admin-core:install --api-auth scaffolds OAuth2 auth (Laravel Passport, password grant):
php artisan admin-core:install --api-auth
It publishes Api\AuthController (/api/login, /api/logout, /api/me) + an ApiAuthServiceProvider
(short-lived tokens: 1h access / 14d refresh; login throttled 6/min), wires routes/api.php (auth routes
- the
Api/Modulesloader) andbootstrap/app.php, and flipsadmin-core.api.middlewaretoauth:api./api/loginproxies the password grant in-process so the client secret never leaves the server:
// POST /api/login {"email":"…","password":"…"} → { "token_type": "Bearer", "access_token": "…", "refresh_token": "…", "expires_in": 3600 }
Passport can't be pulled in by an artisan command, so the install prints the finishing steps:
composer require laravel/passport → vendor:publish --tag=passport-migrations (Passport 12+ no longer
auto-loads them) → migrate (oauth tables) → passport:keys → passport:client --password (put the
id/secret in .env as PASSPORT_PASSWORD_CLIENT_ID/_SECRET) → add the api guard (driver: passport)
to config/auth.php → add Laravel\Passport\HasApiTokens to App\Models\User. Then POST /api/login.
Non-enumerable URLs — the hybrid key strategy (--uuid)
--uuid gives a resource a public UUID for its URLs while keeping a fast bigint primary key:
php artisan admin-core:make Product --uuid --migration --fields="name:string, category_id:foreign"
It generates:
$table->id();— the bigint primary key (all foreign keys and joins use this → lean indexes that never bloat)$table->uuid('uuid')->unique();— the public key used in URLs/APIs (/admin/products/019eadac-…, non-enumerable)foreignId('category_id')->constrained()— bigint FK (notforeignUuid)- a model using the package's
HasPublicUuidtrait, which auto-fills the uuid and setsgetRouteKeyName() => 'uuid'
So you get non-guessable URLs without the index/join cost of uuid primary keys — the best default for a system that may grow. The base BaseService resolves every action by the model's route key, so edit/show/update/delete/bulk-delete/reorder all use the uuid automatically; plain id models (no --uuid) keep using id unchanged.
To make every generated resource hybrid, set 'generator' => ['uuid' => true] in config/admin-core.php
(override per-resource with --no-uuid). The --access module (users/roles/permissions/group-permissions)
ships hybrid too. Use a plain model? Add Ngos\AdminCore\Concerns\HasPublicUuid + a uuid column to any model.
Omitting
--fieldsgives the default singlenamecolumn (backward-compatible). The generated routes are gated bypermission:*middleware. Either assign the new permissions to a role and wrap theadmin-core:routesgroup in['auth', ...], or setpermission.enabled => falseinconfig/admin-core.phpto browse without auth while developing.
Adding a field later (admin-core:field)
admin-core:make scaffolds a resource once; to add a field afterwards (the part you'd otherwise do by
hand — migration and model and views), use admin-core:field:
php artisan admin-core:field Product "sku:string^, discount:decimal?"
php artisan migrate
It generates an add_…_to_products_table migration and surgically patches the model ($fillable, casts,
and the booted() slug-derive hook), the store/update requests (validation rules + the prepareForValidation()
hook for json/password), the form / table-header / DataTable-script / detail (show) views, and the factory
— adding just those fields. Same --fields DSL (so status:enum:a|b also creates the backed
enum class). Fields that already exist are detected and skipped — by the model's $fillable and the
real DB column (so a column that isn't in $fillable is still caught, never producing a duplicate-column
migration). Re-running is safe — pass a mix of old and new and only the new ones are added:
php artisan admin-core:field Product "status:enum:a|b, paid_at:datetime?" # already exists — skipped: status # created …_add_paid_at_to_products_table.php (+ patches)
It resolves the resource by singular name, so admin-core:field Products … and … Product … both
hit the Product model. If the model doesn't exist — or the table has no create migration and doesn't
exist — it refuses up front (so you never get an add_… migration that can't run) and tells you to
admin-core:make … --migration first.
If the resource has an --api channel, the new field is also added to its JsonResource and the
search/sort/filter whitelists (by type) — so it shows up in the API too, not just the admin.
Scope: it handles scalar fields (string/text/number/bool/date/enum/json/slug/password/…). Relation and
upload fields (foreign, belongsToMany, image, file) and system fields (@ / sku / auth —
not mass-assignable, so $fillable can't track them for idempotency) need wiring it can't surgically patch
(model relations, the controller's getData eager-load, the service's pivot-sync / file-storage, a trusted
value-setter), so it skips them with a note — add those by regenerating with admin-core:make … --force.
Patching assumes the views/model still match the generated shape; heavily hand-edited files may need a manual touch-up (it never duplicates, so a re-run won't hurt).
Custom (non-CRUD) page (admin-core:page)
admin-core:make builds CRUD resources; for a standalone page — a Reports screen, a Settings page, a
custom dashboard — use admin-core:page:
php artisan admin-core:page Reports
It scaffolds a thin invokable controller (app/Http/Controllers/Backend/ReportsController.php — fill in
__invoke() with whatever data the page needs), a Blade view (resources/views/backend/pages/reports.blade.php,
already composing <x-admin-core::page-header> + <x-admin-core::card> + a <x-admin-core::empty-state>
placeholder), and a route under routes/Web/Backend/Modules/ (auto-loaded inside the admin group →
admin.reports at /admin/reports). By default it also adds a sidebar menu entry and creates a
view-reports permission granted to the super role — mirroring how admin-core:make wires things.
Multi-word names kebab-case (admin-core:page "Sales Report" → admin.sales-report). Flags: --no-menu,
--no-permission, --force.
Add --report to scaffold a data-driven read-only report instead of a blank page — the controller
hands the view a $rows collection, and the view ships the report shell (a count badge, an empty-state, and
a @foreach table you fill with columns):
php artisan admin-core:page "Low Stock" --report
Multi-portal
Need a second admin area — a merchant or vendor portal with its own login, separate from your staff admin? One command scaffolds the whole thing:
php artisan admin-core:portal merchant
php artisan migrate
php artisan db:seed --class=MerchantSeeder # creates merchant@example.com / password + a full-access role
You get, all on a separate merchant auth guard (its own users, its own login):
App\Models\Merchant(guard-scoped) + migration, a login + dashboard, and a factory + seeder;- a
merchantroute group at/merchant/*(login, dashboard, and its own module folder); - the guard + provider in
config/auth.php, and the menu + super-role entries inconfig/admin-core.php.
Log in at /merchant/login. Then generate resources straight into the portal:
php artisan admin-core:make Order --portal=merchant
php artisan db:seed --class=MerchantSeeder # re-run to grant the new resource's permissions
--portal=merchant routes everything to that portal — routes under /merchant with merchant.* names,
permissions on the merchant guard, and the link added to the merchant sidebar. Add more portals by
changing the name; single-guard apps never touch any of this.
One guard, not separate logins? If admin and merchant are the same users with different roles, skip
--portal/--guardentirely and just give each area a named menu — seeconfig('admin-core.menus').
Notifications
--access installs an in-app notification system on Laravel's database notifications: a bell in the top
bar (<x-admin-core::notifications-bell />) with an unread badge and a recent-list dropdown, a full
notifications page at /admin/notifications, and mark-read / mark-all-read / delete.
Send one in a single line — no notification class to write — with the bundled AdminNotification:
use Ngos\AdminCore\Notifications\AdminNotification; $user->notify(new AdminNotification( title: 'Order shipped', message: "Order #{$order->id} is on its way.", url: route('admin.orders.show', $order), // followed when the row is clicked icon: 'bi-truck', // any Bootstrap icon (optional) extra: ['order_id' => $order->id], // optional extra payload keys ));
Need mail/broadcast/queued, or richer logic? Write your own Notification instead — the UI only needs
toArray() to return title / message / url / icon:
public function via($notifiable): array { return ['database']; } public function toArray($notifiable): array { return ['title' => 'Order shipped', 'message' => '…', 'url' => '…', 'icon' => 'bi-truck']; }
The bell renders only where the routes exist (Route::adminCoreNotifications(), added to the admin group by
--access) and the user is Notifiable — so it's safe everywhere. Existing installs: re-run
php artisan admin-core:install --access to add the table, route and bell, then php artisan migrate.
Realtime (live bell)
By default the bell is pull-based (updates on page load). Turn on realtime and each AdminNotification
also broadcasts, so the bell's badge bumps live and a toast pops on arrival — no refresh. It's opt-in
because it needs a broadcaster + Laravel Echo + a queue worker:
- Enable it:
ADMIN_CORE_REALTIME=true(orconfig('admin-core.notifications.realtime')). Per-notification override:new AdminNotification(..., broadcast: true). - A broadcaster — Reverb (first-party, self-hosted) is easiest:
composer require laravel/reverb && php artisan reverb:install, then runphp artisan reverb:start. (Pusher works too — the kit'secho.jssupports both.) - Front-end env (read at build time, then
npm run build):VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Echo + pusher-js are lazy-loaded only when a key is set — with realtime off they're not in the bundle. - Channel auth — notifications broadcast on the private channel
App.Models.User.{id}. Fresh Laravel apps already authorize this inroutes/channels.php; if yours doesn't:Broadcast::channel('App.Models.User.{id}', fn ($user, $id) => (int) $user->id === (int) $id);
- Run a queue worker (
php artisan queue:work) — broadcasts are queued.
That's it: $user->notify(new AdminNotification(...)) now lands live. The kit listens on the user's channel
(resources/js/realtime.js) and updates the bell; the dropdown list itself refreshes on the next open/load.
Translation & multi-language
Two features, both middleware-based (no public translate endpoint to secure) and multi-language —
list every language you offer in config('admin-core.translation.locales'):
'translation' => [ 'driver' => env('ADMIN_CORE_TRANSLATOR', 'mymemory'), // mymemory | libretranslate | null 'locales' => ['en' => 'English', 'km' => 'ខ្មែរ', 'th' => 'ไทย'], // add as many as you like 'default' => 'en', ],
1. Per-user UI language. Each user gets their own language — one admin in English, another in Khmer. Drop the switcher in your topbar:
<x-admin-core::language-switcher />
Each item is a plain ?setlang=km link the SetLocale middleware picks up, applies with App::setLocale(),
and remembers — persisted to a users.locale column (a migration for it ships with --access, so per-user
language is durable across devices out of the box; without the column it falls back to the session). The
middleware auto-registers on the web group, so no route changes are needed. Use the shipped strings via
__('admin-core::admin-core.actions.save'), etc. (publish/extend with --tag=admin-core-lang; for a no-access
install, add the column yourself: $table->string('locale', 8)->nullable();).
2. Content auto-translate (bidirectional). For multilingual data (e.g. a product name per language), render a translatable input:
<x-admin-core::translatable-input name="name" label="Name" :value="old('name', $product->name ?? [])" />
It shows one box per locale (name[en], name[km], …) plus a hidden marker. On save, the AutoTranslate
middleware takes whichever language you filled and fills the empty ones for you — type Khmer, get English
(and Thai, …); or type English, get the rest. It runs inside the authenticated, CSRF-protected submit, never
overwrites what you typed, and caps outbound calls per request. (Store the values however you like — e.g. a
JSON-cast attribute or a translatable package.)
Auto-generate a language file. Instead of translating the UI strings by hand, machine-translate them:
php artisan admin-core:translate th # writes lang/vendor/admin-core/th/admin-core.php via the driver php artisan admin-core:translate vi --force # re-translate everything (default: keep existing keys)
Then add the code to translation.locales and review the output (machine translation is a draft).
Drivers & privacy. mymemory is free and needs no key. For privacy, point libretranslate.url at a
self-hosted instance so text never leaves your servers. null disables auto-translate (UI language still
works). All drivers are fail-safe — if the provider is down, the original text is kept, so a save never
breaks. API keys live in .env, server-side only. Machine output is a draft to review, especially for Khmer.
Lifecycle commands
php artisan admin-core:version # show the installed package version php artisan admin-core:uninstall # un-wire (remove the route/middleware blocks + User trait) php artisan admin-core:uninstall --purge # also delete the files it published php artisan admin-core:reinstall [--access] # purge + reinstall (clean re-scaffold)
Everything install injects is wrapped in // >>> admin-core:* … // <<< admin-core:* sentinels, so
uninstall removes exactly what it added. Your admin-core:make-generated resources are never
touched — only package-owned files (config, layout, access module, front-end kit) are purged. Add
--force to skip the confirmation prompt.
UI components & theme
The --access kit ships a custom Bootstrap-5 theme (no AdminLTE) plus reusable Blade components:
-
<x-admin-core::page-header title="…" description="…">— breadcrumb + title + description, with an<x-slot:actions>for the primary button. For sub-pages, addparent+:parent-urlfor aDashboard › Posts › Edittrail. Used on every index / create / edit / show. -
<x-admin-core::filter-tabs table="#x_table" :column="2" :tabs="['' => 'All', 'draft' => 'Draft']" />— segmented tabs that drive a server-side DataTables column search (auto-added for enum fields). -
<x-admin-core::data-table id="products_table" thead="…partials.thead">— the list-page shell: a card with an<x-slot:toolbar>(export / import / bulk-delete), the<table>your DataTable binds to, and a default slot under it (e.g. the sort panel). Every generated index uses it. -
<x-admin-core::export-menu :route="route('admin.products.export')" :fields="['name' => 'Name', …]" />— the CSV export dropdown with a per-column checkbox picker (all checked = everything). -
<x-admin-core::import-modal :route="route('admin.products.import')" :template="…" title="Products" />— the “Import CSV” button + modal (file upload, optional blank-template link). Gate it with@can(...). -
<x-admin-core::form-row name="price" label="Price">…control…</x-admin-core::form-row>— one labelled horizontal field row with the validation-error message wired; the generated forms emit one per field. -
<x-admin-core::editor name="description" label="…" :value="old('description', $object?->description)" min-height="250px" />— a CKEditor 5 rich-text field (loaded from CDN, paste-from-Word cleanup);min-heightsets the editable area height (CKEditor 5 otherwise collapses to ~1 line). Generated forms use it for richtextfields. -
<x-admin-core::repeater name="units" :rows="old('units', $rows)" row="…partials.unit-row" add-label="Add unit" />— repeatable rows for a master-detail form (a variant's units, an order's line items). You supply a row partial that renders one row's inputs named with the:indexit's given (e.g.name="units[{{ '$index' }}][unit_id]"), wrapping each row in[data-ac-repeater-row]with a[data-ac-repeater-remove]button. The component renders it once per existing row, plus a hidden<template>the Add button clones with a fresh unique index. Add/remove is inline JS (no build step); it postsname[i][...]arrays (indexes need not be sequential — re-index server-side). -
<x-admin-core::page-loader />— a thin top progress bar shown during full-page navigation; drop it once in the layout. Pairs with the pre-paint sidebar-state script for a flash-free refresh. -
<x-admin-core::status :value="$object->status" />— the soft.ac-statuspill for an enum value (accepts a backed-enum instance or a string; blank renders nothing). Used in the table and show view. Semantic colours for common words: published/active → green, pending → amber, failed/cancelled → red, archived → muted; unknown values fall back to neutral. -
<x-admin-core::stat-list title="Summary" :items="[['label' => 'Refund', 'value' => '-35.00', 'suffix' => 'USD']]" />— a label→value summary card (right-aligned tabular numbers, negatives in red,'strong' => truefor totals). -
<x-admin-core::stat-card label="Users" :count="$n" icon="bi-people" :route="route('…')" tone="1" />— a dashboard KPI card (big number + label + icon, optionally a link;tone1-4 picks the accent). The dashboard composes these. -
<x-admin-core::card>— a Bootstrap card with optional<x-slot:header>/<x-slot:footer>; the body is wrapped incard-body(pass:body-class="''"to drop the wrapper, e.g. a flush table). Used by the generated show/create/edit and the dashboard panels. -
<x-admin-core::form-actions submit="Create" :cancel="route('…index')" />— the submit + cancel row at the foot of a form (pass:submit-class="config('class.button.update')"on edit). Every generated and access-module form uses it. -
<x-admin-core::alert type="warning" dismissible>…</x-admin-core::alert>— an inline contextual message with a leading icon (info/success/warning/danger;error→ danger). For page-level messages; one-off flash is still handled by the layout. -
<x-admin-core::modal id="editX" title="Edit" size="lg">… <x-slot:footer>…</x-slot></x-admin-core::modal>— a reusable Bootstrap modal shell (title/body/footer slots); trigger from anydata-bs-target="#editX". -
<x-admin-core::empty-state icon="bi-inbox" title="Nothing yet" message="…"><x-slot:action>…</x-slot></x-admin-core::empty-state>— a centered placeholder (icon + title + message + optional CTA) for empty lists/sections. -
<x-admin-core::skeleton :lines="3" />/type="card"/type="table" :rows="5" :cols="4"— animated loading-skeleton placeholders to show while content loads, then swap for the real thing (shimmer is dark-mode aware). -
<x-admin-core::tabs :tabs="['profile' => 'Profile', 'security' => 'Security']">with a<x-admin-core::tab-pane id="profile" active>…</x-admin-core::tab-pane>per id — Bootstrap content tabs for multi-section pages/forms (:pills="true"for the pill style). Distinct fromfilter-tabs(DataTable column search). -
<x-admin-core::avatar :src="$user->avatar_url" :name="$user->name" size="40" />— a round photo, or a stable colour + initials circle when there's no image. -
<x-admin-core::badge tone="danger" pill>3</x-admin-core::badge>— a small count/label badge (tone→ Bootstraptext-bg-*). For an enum status pill usestatusinstead. -
Customize drawer (palette icon in the topbar): theme (light/dark/system), accent colour, density, layout (sidebar/top-nav), container (fluid/boxed) and direction (LTR/RTL) — persisted in
localStorage. -
Row actions render as a kebab (⋯) menu (View / Edit / Delete). Add your own items — an "Approve" button, a "Change password" link — via the 3rd arg of
actions()in the generated controller'sgetData(). Each item is['label' => …, 'url' => …]plus optionalicon/can(a permission that gates it) /class; they render above Edit/Delete:->addColumn('actions', fn ($row) => $this->actions($row, 'order', [ ['label' => 'Approve', 'url' => route('admin.orders.approve', $row->getRouteKey()), 'icon' => 'bi bi-check2-circle', 'can' => 'edit-order'], ]))
Re-skin the whole thing from the --ac-* CSS tokens / SCSS variables at the top of resources/sass/app.scss.
Customising
- Stubs:
php artisan vendor:publish --tag=admin-core-stubs→stubs/admin-core/(yours win over the package's). - DataTable partials:
php artisan vendor:publish --tag=admin-core-views→resources/views/vendor/admin-core/. - Config: edit
config/admin-core.php. - Uploads (compression + CDN): all image/file uploads go through
Ngos\AdminCore\Support\Media. Setuploads.compress/max_width/qualityto control WebP compression,uploads.diskto store on any filesystem (e.g. s3 + CloudFront for a CDN), anduploads.cdn_urlto prepend a CDN base URL when building image URLs. All inconfig/admin-core.php(ADMIN_CORE_UPLOAD_DISK/ADMIN_CORE_CDN_URL). - Base model for generated models: set
generator.base_modelinconfig/admin-core.phpand everyadmin-core:makemodelextendsit. Share common behaviour byuse-ing traits in your base (keep the logic in traits soRole/Permission— which must extend Spatie's classes — canusethem too):// app/Models/BaseModel.php abstract class BaseModel extends \Illuminate\Database\Eloquent\Model { use \Ngos\AdminCore\Concerns\HasPublicUuid; // shared behaviour lives in traits protected $casts = ['published_at' => 'datetime']; } // config/admin-core.php → 'generator' => ['base_model' => \App\Models\BaseModel::class],
Using the core directly
// routes/Web/Backend/Modules/products.php Route::group(['prefix' => 'products', 'as' => 'products.'], function () { Route::crud('product', \App\Http\Controllers\Backend\ProductController::class); });
class ProductController extends \Ngos\AdminCore\Http\Controllers\WebController { /* $service, $viewPath, $routeBase, $storeRequest, $updateRequest */ } class ProductService extends \Ngos\AdminCore\Services\BaseService { /* $model */ }
The web and API controllers share a common spine, so generated controllers stay thin and cross-cutting concerns have one home:
BaseController (service + FormRequest bindings; your shared seam)
├── WebController (web: views, redirects, DataTables, export/import) ← thin web controllers
└── ApiController (JSON: index/show/store/update/destroy, paginated) ← thin --api controllers
BaseService is the service-layer equivalent: it holds the model binding + the foundational query(), and
find() flows through it — so a single query() override (e.g. a tenant scope) covers every list, lookup,
update and delete across both the admin and the API.
For master-detail forms (a parent + repeater line items), BaseService::syncHasMany() reconciles the
posted rows — update by id, create the new ones, delete the rest (null leaves the relation untouched).
Pass an $attributes callback to whitelist/derive per-row columns (return null to skip a blank row):
public function create(array $data): Model { $items = $data['items'] ?? null; unset($data['items']); $purchase = parent::create($data); $this->syncHasMany($purchase, 'items', $items, fn ($r) => empty($r['product_id']) ? null : [ 'product_id' => $r['product_id'], 'qty' => $r['qty'] ?? 0, ]); return $purchase; }
Generated resources with a hasMany field wire this automatically.
Testing
The package ships a Pest + Orchestra Testbench suite (in-memory SQLite):
composer install composer test # the full suite composer analyse # Larastan / PHPStan level 5
It covers the FieldSet generator (every field type, UUID, soft-deletes, uploads, m2m, factory), the
Route::crud macro (registration + permission gating), the WebController flow
(store/validate/update/delete/getData/bulk-delete/export), settings, soft-delete trash/restore, and the
two commands end to end: admin-core:make (scaffolds valid, token-free, php -l-clean files whose
migration actually runs) and admin-core:install (config/view publishing + the routes/web.php /
bootstrap/app.php wiring, including idempotency). Dedicated regression guards cover the hybrid-key
edit/show route links (uuid, not bigint id), enum status pills, segmented filter tabs, the consistent
create/edit/show page-header, and the group-permission uuid fill. CI runs both test and analyse on
PHP 8.3 + 8.4.
License
MIT