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.

Maintainers

Package info

github.com/ngouyoung/admin-core

pkg:composer/ngos/admin-core

Statistics

Installs: 73

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.51.17 2026-06-24 16:33 UTC

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

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 --build flag above runs it; otherwise run npm install && npm run build yourself. (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

--access ships its own create_permission_tables migration (uuid + group_id aware) — don't also vendor:publish Spatie's, or migrate fails 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; the admin route group is auth-gated.
  • Users / Roles / Permissions screens built on the CRUD core, with role/permission assignment.
  • App\Models\Role / App\Models\Permission (extending spatie), HasRoles on App\Models\User, the sidebar, and an AccessSeeder (an admin role with every permission + the admin@example.com user).
  • The themed front-end kit (--build runs npm 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 DSLname: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 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_idcategories), 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 a Setting::get('key') helper (cached), gated by the manage-settings permission. Seeded with app_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 its can permission or its route doesn't exist (so menus for un-installed features vanish on their own), and empty section headers are pruned. admin-core:make appends 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 the menu_items table — 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 with php 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 merchant scaffolds its own-guard user model + migration, login + dashboard, route group, and menu/permission config. Then admin-core:make Order --portal=merchant generates straight into it: routes under routes/Merchant/Modules with merchant.* names, permissions + gates on the merchant guard, and a menus.merchant entry 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 show page + 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 Export button (a dropdown with a checkbox per field) streams the chosen columns to CSV (export route + ?columns[]=, gated by list-*; 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 Import button opens a modal to upload a CSV (same shape as Export). The modal links a blank template (importTemplate route) — 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 with id/uuid/timestamps imports cleanly), invalid rows are skipped and reported (import route, gated by create-*).
  • Bulk delete — a select-all checkbox column + a "Delete selected" button that soft/hard-deletes the chosen rows in one request (bulkDelete route, gated by delete-*).

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/Modules loader) and bootstrap/app.php, and flips admin-core.api.middleware to auth:api. /api/login proxies 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/passportvendor:publish --tag=passport-migrations (Passport 12+ no longer auto-loads them) → migrate (oauth tables) → passport:keyspassport: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 (not foreignUuid)
  • a model using the package's HasPublicUuid trait, which auto-fills the uuid and sets getRouteKeyName() => '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 --fields gives the default single name column (backward-compatible). The generated routes are gated by permission:* middleware. Either assign the new permissions to a role and wrap the admin-core:routes group in ['auth', ...], or set permission.enabled => false in config/admin-core.php to 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 merchant route group at /merchant/* (login, dashboard, and its own module folder);
  • the guard + provider in config/auth.php, and the menu + super-role entries in config/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/--guard entirely and just give each area a named menu — see config('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:

  1. Enable it: ADMIN_CORE_REALTIME=true (or config('admin-core.notifications.realtime')). Per-notification override: new AdminNotification(..., broadcast: true).
  2. A broadcasterReverb (first-party, self-hosted) is easiest: composer require laravel/reverb && php artisan reverb:install, then run php artisan reverb:start. (Pusher works too — the kit's echo.js supports both.)
  3. 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.
  4. Channel auth — notifications broadcast on the private channel App.Models.User.{id}. Fresh Laravel apps already authorize this in routes/channels.php; if yours doesn't:
    Broadcast::channel('App.Models.User.{id}', fn ($user, $id) => (int) $user->id === (int) $id);
  5. 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, add parent + :parent-url for a Dashboard › Posts › Edit trail. 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-height sets the editable area height (CKEditor 5 otherwise collapses to ~1 line). Generated forms use it for rich text fields.

  • <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 :index it'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 posts name[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-status pill 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' => true for 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; tone 1-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 in card-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 any data-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 from filter-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 → Bootstrap text-bg-*). For an enum status pill use status instead.

  • 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's getData(). Each item is ['label' => …, 'url' => …] plus optional icon / 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-stubsstubs/admin-core/ (yours win over the package's).
  • DataTable partials: php artisan vendor:publish --tag=admin-core-viewsresources/views/vendor/admin-core/.
  • Config: edit config/admin-core.php.
  • Uploads (compression + CDN): all image/file uploads go through Ngos\AdminCore\Support\Media. Set uploads.compress/max_width/quality to control WebP compression, uploads.disk to store on any filesystem (e.g. s3 + CloudFront for a CDN), and uploads.cdn_url to prepend a CDN base URL when building image URLs. All in config/admin-core.php (ADMIN_CORE_UPLOAD_DISK / ADMIN_CORE_CDN_URL).
  • Base model for generated models: set generator.base_model in config/admin-core.php and every admin-core:make model extends it. Share common behaviour by use-ing traits in your base (keep the logic in traits so Role/Permission — which must extend Spatie's classes — can use them 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