Set up an Admin CRUD
Adding a new read-only or CRUD resource to the admin control panel touches half a dozen files across three directories. A first attempt usually forgets one of them — the menu entry, the translation key, the permission registration — and the page appears broken until the missing piece is filled in.
This recipe lists every file to create, in the order to create them, so a new resource lands complete on the first try. Follow this pattern when the page is backed by an existing domain contract: a sidebar entry with an index page (filter form + paginated table) and a detail page.
A canonical example to crib from: Messages — see src/Presentation/Admin/Controllers/Messages/MessageController.php and src/Presentation/Admin/Views/messages/.
The shape — what you're building
Six things, in order:
- A controller in
src/Presentation/Admin/Controllers/{Feature}/that injects a domain contract and delegates to it. - A route registered in
src/Presentation/Admin/Routes/web.php. - Views (
index.blade.php,show.blade.php) insrc/Presentation/Admin/Views/{features}/. - Permissions named
cp.{features}.{action}, registered in the permissions seeder. - Translation keys for page titles and permission labels in
lang/en/andlang/ar/. - A menu entry in
app/Packages/Menus/CpMenu.php.
Miss any one of them and the page looks broken in a different way. Work through them top-to-bottom; the checklist at the bottom of this chapter is the verification pass.
1. Controller
Create src/Presentation/Admin/Controllers/{Feature}/{Feature}Controller.php. Constructor-inject the domain contract, delegate to it, and put no business logic in the controller — its only job is HTTP-to-view glue.
namespace Presentation\Admin\Controllers\{Feature};
use App\Http\Controllers\Controller;
use Domain\{Domain}\Contracts\{Domain}Contract;
use Domain\{Domain}\DataTransferObjects\Filter{Entity}Data;
use Illuminate\Http\Request;
class {Feature}Controller extends Controller
{
public function __construct(
protected readonly {Domain}Contract ${domain}Contract,
) {}
public function index(Request $request)
{
return view('cp::{feature}.index', [
'{entities}' => $this->{domain}Contract->get{Entities}(
Filter{Entity}Data::fromArray($request->all()),
)->pagination(),
]);
}
public function show(string $id)
{
return view('cp::{feature}.show', [
'{entity}' => $this->{domain}Contract->get{Entity}($id),
]);
}
}
✅ Good — controller is thin, contract does the work:
public function index(Request $request)
{
return view('cp::messages.index', [
'messages' => $this->messageContract->getMessages(
FilterMessageData::fromArray($request->all()),
)->pagination(),
]);
}
❌ Bad — Eloquent and business logic in the controller:
public function index(Request $request)
{
$messages = Message::query()
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->whereNull('deleted_at')
->latest()
->paginate(20);
return view('cp::messages.index', compact('messages'));
}
The second version bypasses the contract entirely, leaks query knowledge into the presentation layer, and makes the same logic impossible to reuse from another panel.
2. Routes
Add a use import and a Route::resource(...) line in src/Presentation/Admin/Routes/web.php, inside the authenticated group.
use Presentation\Admin\Controllers\{Feature}\{Feature}Controller;
Route::resource('{features}', {Feature}Controller::class)->only(['index', 'show']);
Use ->only([...]) to expose only the routes you actually need. For full CRUD, drop the ->only() call.
messages, not message). Laravel route helpers expect the plural form.3. Views
Create both files in src/Presentation/Admin/Views/{features}/:
index.blade.php— filter form + table with pagination.show.blade.php— detail view of a single record.
Both extend layouts.cp and render breadcrumbs. For date range filters, include the shared partial — don't re-implement it:
@include('cp.common.date_filters_inputs')
4. Permissions and abilities
Permissions follow the naming convention cp.{resource}.{action}:
cp.{features}.show— guards the view (eye) button and the show route.cp.{features}.destroy— guards delete actions (if applicable).
Use @can(...) in views and $this->authorize(...) in controllers when a guard is needed.
✅ Good — guard the link in the listing:
@can('cp.{features}.show')
<a href="{{ route('cp.{features}.show', ${entity}) }}">...</a>
@endcan
❌ Bad — unguarded link:
<a href="{{ route('cp.{features}.show', $entity) }}">View</a>
Without the @can, every role sees the button, regardless of whether the role actually has permission to follow it. Users get a 403 instead of a clean "you can't see this."
Register the ability strings in the permissions seeder so they exist in the roles UI on a fresh database.
5. Translations
Add keys to both lang/en/ and lang/ar/ for each new resource. Miss one locale and the pre-push hook fails — see Git and commits for the hook details.
lang/{locale}/pages.php — page titles used in @extends('layouts.cp', ['title' => __('pages.key')]), breadcrumbs, and the sidebar menu via trans('pages.key'):
'your_resource' => 'Your Resource', // index page title
'your_resource-show' => 'Show Your Resource', // show page title
'your_resource-create' => 'Add Your Resource', // create page title (if applicable)
'your_resource-edit' => 'Edit Your Resource', // edit page title (if applicable)
lang/{locale}/permissions.php — human-readable labels for permission keys, used in the roles UI:
'your_resource' => 'Your Resource',
Place entries near semantically related keys (e.g. near messages for communication-related resources).
php artisan translations:check --changed) will block your push if a key is missing in any locale. Add en and ar together, not separately.6. Sidebar menu
Register the resource in app/Packages/Menus/CpMenu.php by adding an entry to the menu array. Use the flat shape for a single link, or the sub-menu shape for grouped links.
// Simple entry (no sub-items)
[
'title' => trans('pages.your_resource'),
'route' => 'cp.your_resource.index',
'icon' => '<svg ...>...</svg>',
],
// Entry with sub-menu
[
'title' => trans('pages.your_resource'),
'route' => 'cp.your_resource.index',
'icon' => '<svg ...>...</svg>',
'sub_menu' => [
['title' => trans('pages.your_resource'), 'route' => 'cp.your_resource.index'],
['title' => trans('pages.your_resource-create'), 'route' => 'cp.your_resource.create'],
],
],
Without this entry, the route works if you type the URL directly — but nobody will know the page exists.
What to read next
- Controllers — the SSR-specific controller rules referenced above.
- Services and contracts — how the contract you're injecting is shaped.
- DTOs — the shape of the
Filter{Entity}Datayou pass in. - Feature flags — if the sidebar entry needs to be gated by Pennant or
ProjectFlavor. - Git and commits — the pre-push hook that validates your translation keys.