dcat-plus/laravel-admin
A Laravel admin panel package for quickly building backends: CRUD scaffolding, form and table builders, role-based access control, and a customizable UI. Built on the Dcat Admin ecosystem with extra components for rapid admin development.
Install and bootstrap
composer require dcat-plus/laravel-admin
php artisan admin:publish # config + assets + migrations
php artisan admin:install # run migrations, seed admin user
php artisan admin:import <your-seed-or-module> # optional, per Dcat Admin conventions
After install you get:
config/admin.php — the single source of truth. Note route.namespace (default App\Admin\Controllers); everything else (Actions, Renderable, Forms namespaces) is derived from it.app/Admin/ — controllers, routes, bootstrap, and the folders you will actually live in: Actions/, Renderable/, Forms/.public/vendor/dcat-admin/ — published assets. Re-publish after every upgrade or the UI silently breaks. 1.3.0 ships new frontend behaviour for tree and import — this step is mandatory, not optional.Where to look first
skills/references/action-system.md in the repo — still the most complete document in the package. Read sections 2 (type table) and 5 (hooks) before writing anything.app/Admin/routes.php — confirms the URL prefix and the /dcat-api/action endpoints the frontend posts to.config/admin.php → route.namespace — memorize how the generator derives namespaces from it, because that is the #1 source of "class not found".First use case
Build one grid screen with a row-level action button, end to end. Do not start with forms, trees, or import — a grid-row action exercises the whole loop (generator → class → AJAX → handle() → Response) in about twenty lines:
php artisan admin:action grid-row PublishPost
// app/Admin/Actions/Grid/PublishPost.php
public $title = 'Publish';
public function handle(Request $request): Response
{
Post::findOrFail($this->getKey())->update(['status' => 'published']);
return $this->response()->success('Published')->refresh();
}
// in your Grid
$grid->actions(function (Grid\Displayers\Actions $actions) {
$actions->append(new \App\Admin\Actions\Grid\PublishPost());
});
Once that button round-trips, every other action type is a variation on a theme — including the built-in import flow, which is a toolbar action plus a modal under the hood.
Always generate, never hand-write the skeleton. The generator resolves base class, namespace, and the required hooks for you; typos in the parents are the most common self-inflicted wound.
php artisan admin:action # interactive, 4 questions
php artisan admin:action grid-batch ApproveAll # non-interactive: good for CI / AI / scripts
php artisan admin:action lazy-form LoginUserForm --force
Pick the type by container, not by vibe.
| Need | Type | Parent |
|---|---|---|
| Button inside a page/widget | default |
Actions\Action |
| Per-row button in a grid | grid-row |
Grid\RowAction |
| Multi-select operation | grid-batch |
Grid\BatchAction |
| Toolbar button (grid/form/show/tree) | grid-tool / form-tool / show-tool / tree-tool |
AbstractTool |
| Tree node button | tree-row |
Tree\RowAction |
| Modal body that loads a grid | lazy-table |
Grid\LazyRenderable |
| Modal body that loads a form | lazy-form |
Widgets\Form + LazyWidget |
Compose behaviour through hooks instead of overriding rendering. The four you will use daily:
public function confirm() { return ['Publish?', 'This becomes visible immediately.']; }
protected function parameters() { return ['scene' => 'publish', 'flag' => $this->row('status')]; }
protected function authorize($u) { return $u->isAdministrator(); }
protected function actionScript() { return 'function (data, target, action) { /* return false to abort */ }'; }
parameters() is the clean way to pass server-rendered state into the request — better than re-querying in handle(), and it keeps the payload inspectable in DevTools.
Choose the return shape deliberately. Response is a fluent builder and every terminal call maps to a frontend behaviour:
return $this->response()->success('Done')->refresh(); // stay in place
return $this->response()->success('Done')->redirect(admin_url('posts')); // navigate away
return $this->response()->success('Copied')->script("Dcat.info('{$this->getKey()}')"); // server → client value
return $this->response()->error('Forbidden'); // abort
return $this->response()->alert(true)->success('Done'); // Swal instead of Toastr
Batch actions: getKey() is an array. Treat it as a collection and chunk it — the frontend will happily send 500 IDs.
public function handle(Request $request): Response
{
Post::whereIn('id', (array) $this->getKey())->update(['status' => 'approved']);
return $this->response()->success('Approved')->refresh();
}
Model-based grid import (new in 1.3.0). The split is deliberate: enable the entry point on the grid, declare the contract on the model. Keep the mapping and validation there — not in the controller — so the same import works from any grid that renders that model.
// app/Admin/Controllers/PostController.php
$grid->import(); // adds the toolbar/upload entry; confirm exact method name in skills/references
// app/Models/Post.php — the import contract
// column label → validation/transformation, e.g. title, slug, status, published_at
Design rules that survive contact with real spreadsheets:
handle().created_by / updated_by from admin_user() is the only audit trail you will have when a bad file lands.set_time_limit.Lazy rendering is a different subsystem — do not mix it with Actions. lazy-table / lazy-form post to /dcat-api/render and /dcat-api/form, not /dcat-api/action. They are mounted through Modal / Card / LazyTable:
// app/Admin/Renderable/UserPostsTable.php (grid-row action opens this)
return Modal::make()
->lg()
->title('Posts')
->body(UserPostsTable::make(['user_id' => $this->getKey()]))
->success($this->response());
Tree screens got more capable in 1.3.0. The same action model applies — tree-row for node buttons, tree-tool for the toolbar — but you can now lean on the tree layer for more of the interaction instead of hand-rolling it:
// node-level actions, unchanged shape
$tree->actions(function (Tree\Actions $actions) {
$actions->append(new EditNode());
$actions->append(new DeleteNode());
});
Practical guidance: keep the tree model's parent_id, ordering, and depth rules in one place (the model or a base Tree class), because 1.3.0's expanded tree surface means more code paths — drag/sort, node actions, lazy children — can each mutate the same structure. Guard against cycles and cap depth before the UI lets a user create a 40-level tree.
Prefer href() over handle() for anything that is really a link. Defining href() flips allowHandler off, so there is no useless AJAX round-trip:
public function href(): string { return admin_url('posts/'.$this->getKey().'/preview'); }
Group crowded row menus. Three or more actions per row should collapse into a dropdown:
$actions->group(function ($g) {
$g->append(new Approve());
$g->append(new Reject());
}, 'feather icon-more-vertical', 'More');
Gate visibility server-side. $grid->disableDeleteButton() / $actions->disableDelete() removes the button; authorize() still protects the endpoint. Do both — the first is UX, the second is security. This applies double to import: the upload entry is a mutating endpoint, so gate it with authorize() (or an explicit permission check) and not just a hidden button.
composer update → php artisan admin:publish → hard refresh → then touch tree/import code. Skipping the publish step leaves you debugging a UI that is older than the PHP classes calling it.$title and handle() are mandatory for every type except href()-style tools. Forgetting $title yields a nameless blank button with no error.config('admin.route.namespace'), strips the trailing Controllers, and appends Actions / Renderable / Forms. Change that config — or pass --namespace= — or the classes land somewhere the container will never find them.--force is destructive and silent. It will clobber a hand-edited generated class without prompting.Response terminal calls overwrite, they do not queue. redirect()->refresh() refreshes; only the last then-family call wins. Similarly, error() short-circuits the whole chain — set your success message after the business logic succeeds, not before.actionScript() / resolverScript() / rejectScript() must return a JS function expression as a heredoc string. Returning false inside actionScript aborts the request; returning false inside resolverScript suppresses the default success handling — useful when you want the modal to stay open.authorize() defaults to true. A generated action is publicly triggerable by anyone who can guess _action + _key. Override it on anything mutating./dcat-api/action. The payload contains _action (snake_cased class name) and _key. A 403 means authorization, a 500 with a serialization error means the class is not autoloadable (namespace mismatch, stale composer dump-autoload).alert(true) vs. Toastr is a per-action decision, not a global one. Use Swal for destructive confirmations, Toastr for routine feedback — mixing them in one grid feels inconsistent fast.App\Admin\Actions\BaseAction to standardise authorize(), confirm(), and a logging wrapper; override protected html() to render icon buttons; override handleHtmlResponse() when an action returns partial HTML instead of JSON. For import, wrap the model's import contract in a base trait so every importable model gets the same validation, actor stamping, and error reporting.1.x) does not track upstream (2.x), so don't assume API parity from version numbers. Before committing: diff the Action / Grid / Tree APIs against upstream dcat/laravel-admin, pin the version in composer.json, and vendor the skills/references/ docs into your repo — upstream documentation remains your only support channel, and a minor-version tree change is exactly the kind of thing that can bite a subclass.How can I help you explore Laravel packages today?