jaguarjack/catchadmin
Laravel-based admin panel framework for rapid backend development, with built-in RBAC permissions, modular architecture, CRUD code generation, and a Vue front end. Ready-made user, role, menu, and department modules help teams scaffold secure, extensible admin apps quickly.
CatchAdmin is a modular admin-system framework built on Laravel + Vue 3, not a Composer library you require into an existing app. Day-to-day work happens in two sibling directories: modules/ (backend) and web/ (frontend). Anything you do outside those two trees is plain Laravel and will not get permissions, dynamic routing, or the table/form conventions for free.
First 30 minutes
php artisan catch:install (this bootstraps DB, admin user, and module registration). Use composer run dev to bring up the combined backend/asset dev loop.php artisan catch:module:install --all, then per-module php artisan catch:migrate {Module} and php artisan catch:db:seed {Module}.modules/Permissions/ and modules/System/ — read these first, they are the canonical reference for controllers, CatchModel usage, and routes/route.php style.modules/{Module}/Installer.php — how a module announces itself.web/src/views/{module}/ — the frontend counterpart, one directory per module in lowerCamelCase.web/src/components/catchTable/ and web/src/composables/curd/ — the table and CRUD primitives every list page uses.php artisan catch:build produces the assets that public/ serves, so a stale build is the #1 cause of "my backend change did nothing."First real use case: ship one CRUD module end to end
Don't start by hand-writing a controller. Start from a table definition and let the generator produce the vertical slice, then edit the generated files. The pipeline is table → migration → model → controller → route.php → menu → Vue page.
Table: products
Fields:
- name (string, 100, required)
- category_id (foreign key -> categories)
- price (decimal 10,2, required)
- stock (integer, default 0)
- status (tinyint, default 1)
Naming is derived, so getting the input right saves rework: {table} = products (snake plural), {Model} = Product, {Module} = Product, {module} = product, {resources} = products (kebab plural), {resource} = product.
Then, for anything the generator can't scaffold — a new menu, an extra migration, a bespoke model:
php artisan catch:make:migration Product add_sku_to_products
php artisan catch:make:model Product Product products
php artisan catch:make:menu Product ProductController "商品管理"
Your first meaningful edit after generation is almost always $searchable / $fields / $form on the model — that single class drives list filtering, column selection, and form persistence.
Catch\Base\CatchModel is the workhorse. Instead of writing query scopes and request-validated field lists per controller, you declare intent on the model and the base controller honours it.
class Product extends CatchModel
{
protected $table = 'products';
public array $searchable = [
'name' => 'like',
'status' => '=',
'category_id' => '=',
];
protected array $fields = ['id', 'name', 'price', 'stock', 'status', 'created_at'];
protected array $form = ['name', 'category_id', 'price', 'stock', 'status'];
protected string $sortField = 'id';
protected bool $sortDesc = true;
public function category(): BelongsTo
{
return $this->belongsTo(Category::class, 'category_id');
}
}
Supported search operators are like, =, in, between — keep the map small and explicit rather than trying to express joins through it. For many-to-many form submissions use $formRelations = ['roles', 'tags'] so the base layer syncs pivots for you. For tree data (categories, departments) set $parentIdColumn and $asTree = true rather than building recursion in the controller.
Backend controllers receive parameters, coordinate the flow, and return data. Do not wrap in response()->json(['code' => 0, ...]) — the unified envelope is applied automatically, and hand-rolling it breaks the frontend's catch-table and CURD composables.
class ProductController extends Controller
{
public function __construct(private readonly ProductService $service) {}
public function index(): mixed
{
return Product::getList(); // search/sort/paginate applied by CatchModel
}
}
Business logic goes in Models/, Support/, or a service class — not in the controller. Constructor injection with readonly is the house style.
Always check modules/{Module}/routes/route.php before adding anything, and match the existing style. New admin resources default to:
Route::adminResource('products', ProductController::class);
This is what wires the route into the permission system and the dynamic frontend menu. A route declared with the plain Laravel Route::resource will work in Postman and then fail authorization in the browser — a classic time sink.
List pages use catch-table; forms reuse the CURD composables. The conventions that matter:
web/src/views/{module}/index.vue, written as <script setup lang="ts"> with explicit interface types (TS is strict).{resource}/form/create.vue. Full-page form: {resource}/create.vue.primary prop — not id, not row.useCreate / useShow over ad-hoc axios calls; they already handle the response envelope, validation errors, and refresh.web/src/components/admin/ are auto-imported — do not add manual imports for them.Token auth, RBAC/permissions, dynamic routing, pagination wrapper, attachments/uploads, data recycle bin, and Excel import/export are already in the framework. The pattern is to use them: put import classes in modules/{Module}/Excel/Import/, export classes in Excel/Export/, and let the resource-permission layer guard the controller. Building a parallel implementation is the main way developers accidentally fork the framework.
Promote to web/src/components, web/src/composables, or web/src/support only when a capability is reused by two or more modules. Single-module helpers stay inside the module.
Timestamps will bite you. CatchAdmin uses unsignedInteger Unix timestamps and an integer deleted_at, not Laravel's timestamps() / softDeletes(). The standard block on every table:
$table->unsignedInteger('creator_id')->default(0);
$table->unsignedInteger('created_at')->default(0);
$table->unsignedInteger('updated_at')->default(0);
$table->unsignedInteger('deleted_at')->default(0);
Run Laravel's $table->timestamps() instead and date filtering, sorting, and the recycle bin silently misbehave — usually with no error, just empty result sets.
Enums start at 1, and status is fixed. 1 = Enabled, 2 = Disabled. Starting enums at 0 collides with "unset" defaults and breaks generator-produced filters. Index foreign keys ($table->unsignedBigInteger('category_id')->default(0)->index()); the framework does not add them for you.
Module registration is three steps, not one. A new module needs its Installer.php, a service provider under Providers/, and an install run (catch:module:install --all). Miss the install step and routes exist but 404 in the admin UI, or migrations never run because nobody called catch:migrate {Module}.
Generated files are a starting point, not a contract. Generate first, then edit in place — regenerate over a hand-modified controller and your changes are gone. This is why the guidance is "generate then make small modifications" rather than "hand-write from scratch." Keep a mental list of which files you've customised.
Stale assets and stale routes. If a change isn't visible, check php artisan catch:build before debugging Vue. If a new route 404s, check route caching — a cached route table hides new route.php entries.
Permissions are attached to routes, not controllers. Adding a method to an existing controller without a corresponding route entry (or menu via catch:make:menu) means the permission record never exists, and the UI shows a forbidden error for the admin account too. Menu and permission creation is part of "done."
Frontend/backend naming drift is the common failure. {Module} is PascalCase in modules/, {module} is lowerCamelCase in web/src/views/, {resources} is kebab-case plural in routes. A mismatch here produces a menu that points at a nonexistent component — a silent, annoying bug. Derive all three from the same source name.
Version pinning deserves scrutiny. The stack is on the aggressive edge (PHP 8.3+, Laravel 13+, Vue 3.5+, Vite 8+, Pinia 3). Before adopting, confirm your hosting/tooling supports those floors; the Apache-2.0 licence is friendly but the dependency surface is bleeding-edge. With ~940 stars and effectively zero downstream dependents, expect to read source rather than find Stack Overflow answers, and budget for the upgrade treadmill.
Documentation lookup is part of the workflow. The project steers toward Context7/MCP for CatchAdmin-specific questions and Laravel Boost MCP (or official Laravel docs) for framework questions. Internalise that split: CatchAdmin conventions are only reliably documented in-project, so read modules/Permissions and modules/System before assuming standard Laravel behaviour.
Extension points worth knowing. Installer.php (module lifecycle), CatchModel properties (query/form behaviour), $formRelations (pivot sync), Excel/Import|Export classes, and the CURD composables on the frontend. These five cover the large majority of "I need to customise how this behaves" cases without patching the framework.
Style enforcement is mechanical. Every PHP file needs declare(strict_types=1);, typed parameters and returns, PSR-12. Set this up in your editor on day one — retrofitting it across a module is tedious and makes diffs noisy for reviewers.
How can I help you explore Laravel packages today?