DEV Community

Cover image for Filament + Hexagonal: Building an Admin Panel Without Coupling Your Domain
Gabriel Anhaia
Gabriel Anhaia

Posted on

Filament + Hexagonal: Building an Admin Panel Without Coupling Your Domain


You bought into hexagonal a year ago. Your Order is a plain PHP class. It doesn't extend Model. It has invariants, value objects, a refund() method that emits a domain event. The HTTP controllers are adapters. The repository is an interface in your domain layer, implemented by a Doctrine-or-Eloquent adapter on the outside.

Then ops asks for an admin panel. They want filters, bulk actions, a CSV export, audit logs, a button labelled "Refund". You look at Filament. It's the right tool. You scaffold a resource. You read the docs.

Every Filament tutorial opens the same way:

protected static ?string $model = Order::class;
Enter fullscreen mode Exit fullscreen mode

$model must be an Eloquent model. Filament Resources are built on top of Eloquent's query builder, Eloquent's relationships, Eloquent's casts. The whole admin panel (tables, forms, filters, soft deletes, eager loading) assumes your domain object inherits from Illuminate\Database\Eloquent\Model.

Your domain object doesn't. That's the whole point.

What you actually need: a way to point Filament at a read-shaped Eloquent model that has nothing to do with your domain, while keeping every mutation routed through your use cases.

The shape of the answer

  • Order lives in App\Domain\Order. Pure PHP. Doesn't know Eloquent exists. Has methods like refund(), cancel(), markAsShipped() that enforce invariants and return events.
  • OrderProjection lives in App\Filament\Projections. Extends Model. Maps to the same table (or to a denormalised read table). Has no business methods. It exists so Filament can render rows fast.

The rule: Filament Resources read through OrderProjection. Mutations never write through OrderProjection. Every "Refund" or "Cancel" click in the admin panel dispatches a use case that loads the Order aggregate, calls its method, and persists through the domain's repository port.

The projection is a view. The aggregate is the source of truth. They might share a table; they might not. Either way, the projection is read-only as far as the admin is concerned.

Two objects, one table: the read projection vs the domain aggregate

The read projection

Start with the Eloquent model Filament will use. It's small. It exists for the admin and nothing else.

<?php

declare(strict_types=1);

namespace App\Filament\Projections;

use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;

class OrderProjection extends Model
{
    protected $table = 'orders';

    protected $guarded = [];

    public $timestamps = false;

    protected $casts = [
        'total_cents' => 'integer',
        'items' => AsArrayObject::class,
        'status' => OrderStatus::class,
        'placed_at' => 'datetime',
        'refunded_at' => 'datetime',
    ];

    public function scopeRefundable($query)
    {
        return $query->whereIn('status', [
            OrderStatus::Paid,
            OrderStatus::Shipped,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

$guarded = [] is safe here because the projection is never used as a mass-assignment target. Filament never writes to it. The only thing that writes to this table is the domain's repository implementation, and that goes through your aggregate.

OrderStatus is the same PHP 8.1 enum your domain uses. Cast at the projection layer; the domain consumes the same type. No translation layer for enums that are already plain values.

The scopeRefundable query scope is an admin concern, not a domain rule. The domain knows whether an order can be refunded by examining its state. The admin needs a fast SQL filter to render only refundable rows. Both can be true. The projection holds the SQL; the aggregate holds the invariant.

The Filament Resource

Wire the resource against the projection. Standard Filament, nothing exotic.

<?php

declare(strict_types=1);

namespace App\Filament\Resources;

use App\Domain\Order\OrderStatus;
use App\Filament\Projections\OrderProjection;
use App\Filament\Resources\OrderResource\Pages;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class OrderResource extends Resource
{
    protected static ?string $model = OrderProjection::class;

    protected static ?string $navigationIcon = 'heroicon-o-shopping-bag';

    protected static ?string $modelLabel = 'Order';

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('id')
                    ->label('Order')
                    ->copyable(),
                Tables\Columns\TextColumn::make('customer_id')
                    ->searchable(),
                Tables\Columns\TextColumn::make('total_cents')
                    ->money('EUR', divideBy: 100)
                    ->sortable(),
                Tables\Columns\TextColumn::make('status')
                    ->badge(),
                Tables\Columns\TextColumn::make('placed_at')
                    ->dateTime()
                    ->sortable(),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('status')
                    ->options(OrderStatus::class),
            ])
            ->actions([
                Tables\Actions\ViewAction::make(),
                RefundOrderAction::make(),
            ])
            ->defaultSort('placed_at', 'desc');
    }

    public static function form(Forms\Form $form): Forms\Form
    {
        return $form->schema([]);
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListOrders::route('/'),
            'view' => Pages\ViewOrder::route('/{record}'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

No EditAction. No CreateAction. No DeleteAction. The admin doesn't create or mutate orders through Filament's default CRUD path. If an operator needs to refund, they click a custom action that routes through a use case. If they need to view, they get a read-only page. That's the whole interaction surface.

This is the part most teams skip. They scaffold the resource, leave EditAction in, and within a week someone on ops has edited total_cents directly through the admin to "fix" a bug. The invariant your aggregate guarded just got bypassed. No domain event was emitted because nobody wrote one. Accounting reconciles wrong the next morning.

Take the edit and create actions out. PHP's linter won't tell you they're missing. You have to be deliberate.

The custom action that dispatches a use case

Here's the part that makes the wiring honest. A Filament action that looks like every other action, but underneath, it calls into your domain.

<?php

declare(strict_types=1);

namespace App\Filament\Resources;

use App\Application\RefundOrder\RefundOrderInput;
use App\Application\RefundOrder\RefundOrderUseCase;
use App\Domain\Order\OrderId;
use App\Filament\Projections\OrderProjection;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;

class RefundOrderAction extends Action
{
    public static function getDefaultName(): ?string
    {
        return 'refund';
    }

    protected function setUp(): void
    {
        parent::setUp();

        $this->label('Refund')
            ->icon('heroicon-o-arrow-uturn-left')
            ->color('danger')
            ->requiresConfirmation()
            ->modalHeading('Refund this order')
            ->visible(fn (OrderProjection $record): bool => in_array(
                $record->status->value,
                ['paid', 'shipped'],
                true,
            ))
            ->form([
                Forms\Components\Textarea::make('reason')
                    ->required()
                    ->minLength(10)
                    ->maxLength(500),
            ])
            ->action(function (
                OrderProjection $record,
                array $data,
                RefundOrderUseCase $useCase,
            ): void {
                $result = $useCase->execute(new RefundOrderInput(
                    orderId: new OrderId($record->id),
                    reason: $data['reason'],
                    issuedBy: auth()->user()->email,
                ));

                Notification::make()
                    ->title('Refund issued')
                    ->body("Refund {$result->refundId->value} created.")
                    ->success()
                    ->send();
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

The action's visible() closure reads from the projection. That's a UI concern — "should this button show up in the table" — and the projection's columns are the fastest way to answer it. The hard rule about whether a refund is allowed still lives in the aggregate; this is just whether to render the button.

The action() callback takes RefundOrderUseCase as a parameter. Laravel's container resolves it. The use case knows nothing about Filament, nothing about HTTP, nothing about the projection. It receives a plain DTO (RefundOrderInput), loads the domain Order via the repository port, calls $order->refund($reason), persists, and returns a plain DTO with the refund ID. The admin button and a queued job and a REST endpoint can all call it the same way.

The notification reads from the use case's output, not from the projection. The projection is stale at this point: the row in the table still says paid because the page hasn't re-fetched yet. The use case's return value is fresh. Trust the use case. Filament will refresh the row on the next render.

The use case, briefly

For completeness, here is what the use case looks like. It's the part of the diagram that doesn't know Filament exists.

<?php

declare(strict_types=1);

namespace App\Application\RefundOrder;

use App\Domain\Order\OrderRepository;
use App\Domain\Order\RefundReason;
use App\Domain\Shared\Clock;
use App\Domain\Shared\EventBus;

final readonly class RefundOrderUseCase
{
    public function __construct(
        private OrderRepository $orders,
        private EventBus $events,
        private Clock $clock,
    ) {
    }

    public function execute(RefundOrderInput $input): RefundOrderOutput
    {
        $order = $this->orders->byId($input->orderId);

        $refund = $order->refund(
            new RefundReason($input->reason),
            $input->issuedBy,
            $this->clock->now(),
        );

        $this->orders->save($order);

        foreach ($order->pullEvents() as $event) {
            $this->events->publish($event);
        }

        return new RefundOrderOutput(refundId: $refund->id);
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderRepository, EventBus, and Clock are interfaces in App\Domain. Their implementations sit in App\Infrastructure and App\Adapters. Filament is one of several adapters that consume this use case. The dependency graph points inward; nothing in the domain ever points out at Laravel or Filament.

The dependency arrows: Filament adapter consumes the use case; the use case consumes domain ports

When the read table and the write table diverge

The example above shares one table between the projection and the aggregate. That works while the admin's data needs match the domain's storage. It stops working when:

  • The admin wants joined customer data on every row and your domain stores customer IDs only.
  • The admin wants aggregated totals (last 30 days of refunds per customer) that don't fit on the orders row.
  • Your domain switches from a SQL repository to an event-sourced one and orders becomes a stream, not a table.

The fix is to make OrderProjection map to a separate denormalised read table (order_projections or orders_admin) populated by a listener on your domain events. When OrderRefunded fires, a projector handler updates the read row. Filament reads from order_projections. The aggregate stays clean and the admin stays fast. The admin's table can grow new columns without ever touching the domain.

The projection table is allowed to be eventually consistent. The admin is allowed to show a row that is two seconds behind the actual aggregate state. What is not allowed is for the admin to mutate the projection table directly. Every change still goes through a use case.

What this buys you

Filament still ships your admin panel in a week. Filters, exports, audit logs, role-based access, all of it. No bespoke admin written from scratch in Vue. At the same time, the Order aggregate stays pure PHP, the refund rule still lives in one place, the domain event still fires, and the use case is still testable without a database. The next framework migration (Laravel 13, Symfony, whatever) touches the adapters, not the core.

The audit trail survives intact too. Every mutation that hits the system goes through a use case, every use case publishes events, and the admin panel is a caller rather than a back door. When ops refunds an order at 3am, the trace is identical to the one a customer self-service refund leaves: same use case, different issuedBy.

That is the entire pattern. Pin those two arrows in place and Filament becomes one more adapter at the edge of your hexagon, instead of the framework that swallowed your domain.


If this was useful

This is one of the patterns from Decoupled PHP. The book walks the same shape from a single use case up to a multi-adapter production service: HTTP, CLI, queues, an admin panel, all sitting outside a framework-free domain. It is honest about where ceremony pays off and where it doesn't, and it covers the migration path for legacy Laravel and Symfony codebases that need to move incrementally without a freeze week.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now; Portuguese and Spanish coming soon.

Top comments (0)