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

πŸ‘ ngouyoung

Package info

github.com/ngouyoung/admin-core

pkg:composer/ngos/admin-core

Statistics

Installs: 76

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.62.4 2026-06-28 11:58 UTC

Suggests

  • laravel/passport: Required for `admin-core:install --api-auth` β€” OAuth2 (password grant) API auth for a decoupled front-end

Provides

None

Conflicts

None

Replaces

None

MIT a620d6909e4651529e7178e244f7ee15ea441879

  • ngos <ngouyuong.woop@gmail.com>

admingeneratorthemebootstrapcrudscaffoldlaraveldatatables

This package is auto-updated.

Last update: 2026-06-28 11:58:28 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.

πŸš€ New here? Start with the step-by-step tutorial β€” it builds a working catalog admin from scratch (install β†’ categories β†’ products with a relation, image & status β†’ roles β†’ a custom action) and explains every step. The reference below is for once you know the loop.

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 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 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 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
}

Custom table actions

Beyond delete, every list can carry custom bulk + per-row actions. Declare them once in the controller's resourceActions() and the package wires the toolbar button, the row-menu item, the route, the permission gate, the confirm dialog and the toast:

use Ngos\AdminCore\Actions\Action;

protected function resourceActions(): array
{
 return [
 Action::make('mark-paid')->label('Mark as paid')->icon('bi bi-cash')->color('success')->confirm()
 ->handle(fn ($records) => $records->each->update(['status' => 'paid'])),
 ];
}

The handler receives the selected models β€” resolved through the resource query, so scopes / soft-deletes / tenancy apply (you can only act on rows you can see). Fluent options: ->permission('…') (defaults to {key}-{resource}), ->withoutPermission(), ->onlyBulk(), ->onlyOnRow(), ->confirm('Sure?'), ->success('Done!'). The permission is enforced server-side β€” hiding a button is cosmetic. Add the permission (e.g. mark-paid-product) to your seeder.

Field-level permissions

Lock individual fields to a permission β€” a user without it can't see the field (it's disabled in the form) nor write it (stripped server-side, so a crafted POST can't set it either):

protected function fieldPermissions(): array
{
 return ['status' => 'change-status-order', 'cost' => 'edit-cost-product'];
}

Covers direct fillable columns. On update the stored value is merged past validation, so locking a required field still lets a restricted user save the rest of the form.

Approval workflow

Mark a sensitive action ->requiresApproval() and it won't run for a user who can request it but not approve it β€” instead it files a pending request that an approver clears from the Approvals inbox:

Action::make('refund')->requiresApproval()
 ->handle(fn ($records) => $records->each->update(['status' => 'refunded']));
  • A requester (has refund-order, not approve-refund-order) β†’ files a request; approvers are notified.
  • An approver opens admin.approvals.index β†’ Approve (re-runs the action over the captured rows) or Reject (with a reason); the requester is notified of the decision. A user who can approve runs it directly (no self-request).

Run php artisan admin-core:install (adds Route::adminCoreApprovals()) + php artisan migrate (the approvals table), and grant approve-{action}-{resource} to your approver role.

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/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 (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

Media library

A browsable, reusable media library. Add a media (single) or gallery (multiple) field and the generator wires the whole flow β€” a picker control, validation, and the save:

php artisan admin-core:make Product --fields="name:string, cover:media, photos:gallery"

These are relations, not columns, so one library file can be reused across records. The owning model gets the HasMedia trait:

$product->mediaIn('photos'); // ordered collection of attached files
$product->firstMediaUrl('cover'); // single hero-image url
$product->syncMedia([$ids], 'photos');

The library screen (browse / upload / delete) lives at admin.media.index β€” wired by admin-core:install (Route::adminCoreMedia()), gated by manage-media. Uploads are compressed (WebP) and stored on the configured disk; deleting a file is refused while it's still attached to a record.

Dashboard widgets

A config-driven dashboard: drop <x-admin-core::dashboard /> into your dashboard view and declare the widgets in config('admin-core.dashboard.widgets'). A widget is either a class or an inline array:

'widgets' => [
 \App\Dashboard\RevenueWidget::class, // a class widget
 ['type' => 'stat', 'title' => 'Users', 'icon' => 'bi-people', // an inline widget
 'value' => fn ($c) => \App\Models\User::query()->count(), 'link' => '/admin/users'],
 ['type' => 'chart', 'title' => 'Signups', 'col' => 6,
 'chart' => fn ($c) => ['type' => 'line', 'series' => [/* … */], 'categories' => [/* … */]]],
 ['type' => 'list', 'title' => 'Latest orders',
 'rows' => fn ($c) => [['label' => '…', 'meta' => '…', 'link' => '…']]],
],

Scaffold a class widget with php artisan admin-core:make-widget Revenue --type=stat (or chart / list) β€” it extends StatWidget / ChartWidget / ListWidget. The $c (DashboardContext) carries the active date range: $c->scope($query) filters to it, $c->scopePrevious($query) to the previous period (for trends). A date-range toolbar and per-user drag-reorder/hide (saved per user) are on by default (dashboard.date_filter / dashboard.customizable). Wire it with Route::adminCoreDashboard() (added by admin-core:install).

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 broadcaster β€” Reverb (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-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. 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