Going Further

Set up an Admin CRUD

Step-by-step recipe for adding a new admin dashboard page — controller, route, views, permissions, translations, and sidebar menu — in the order to do them.

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:

  1. A controller in src/Presentation/Admin/Controllers/{Feature}/ that injects a domain contract and delegates to it.
  2. A route registered in src/Presentation/Admin/Routes/web.php.
  3. Views (index.blade.php, show.blade.php) in src/Presentation/Admin/Views/{features}/.
  4. Permissions named cp.{features}.{action}, registered in the permissions seeder.
  5. Translation keys for page titles and permission labels in lang/en/ and lang/ar/.
  6. 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.

Pluralise the URL segment (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).

The pre-push hook (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.

  • 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}Data you 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.