Skip to main content

Why a modular monolith?

For an SMB SaaS platform targeting developing regions, microservices introduce operational overhead — distributed tracing, network partitions, independent deployment pipelines, and polyglot data stores — that far outweigh the benefits at this scale. A modular monolith gives you:
  • Low operational cost — single deployment unit, single database, one process to monitor
  • Strong module boundaries — enforced by automated architecture tests, not by network topology
  • Refactorability — modules can be extracted to services later if load demands it; clean boundaries make that migration mechanical rather than heroic
  • Type safety end-to-end — a single PHP → TypeScript contract sync covers the entire surface area
The hardest part of microservices is not running them — it’s keeping the contracts between them honest. In a modular monolith, the compiler and test suite do that work for you.

Directory structure

app/
├── Domain/          # Layer 1 — Pure business logic
│   ├── Identity/
│   ├── Sales/
│   ├── Billing/
│   ├── Booking/
│   ├── Marketing/
│   └── ...          # One directory per bounded context
├── Application/     # Layer 2 — Use-case orchestration
│   ├── Identity/
│   │   ├── Commands/
│   │   ├── Handlers/
│   │   └── Results/
│   └── ...
├── Infrastructure/  # Layer 3 — Adapters and persistence
│   ├── Persistence/
│   ├── Repositories/
│   ├── Bus/
│   └── ...
├── Http/            # Layer 4 — HTTP delivery
│   ├── Controllers/
│   ├── Requests/
│   └── Resources/
└── Filament/        # Admin panels (Filament 4.2)

resources/
└── js/              # React 18 + TypeScript frontend

The four strict layer rules

Rule 1 — Domain knows nothing outside itself

Entities, enums, and repository interfaces are pure PHP. No Laravel facades, no Eloquent, no HTTP.
// app/Domain/Identity/Entities/UserEntity.php

final class UserEntity
{
    private string $id;
    private string $name;
    private string $email;
    private UserRole $role;
    private ?string $tenantId = null;

    private function __construct() {}

    public static function register(
        string $uuid,
        string $name,
        string $email,
        string $passwordHash,
        UserRole $role,
        ?string $tenantId = null
    ): self {
        $user = new self;
        $user->password = $passwordHash;
        // Records a domain event — no DB, no HTTP
        $event = UserRegistered::now(
            user_id: $uuid,
            name: $name,
            email: $email,
            role: $role->value,
            tenant_id: $tenantId,
        );
        $user->recordThat($event);
        return $user;
    }
}
// app/Domain/Identity/Repositories/UserRepositoryInterface.php

interface UserRepositoryInterface
{
    public function save(UserEntity $user): void;
    public function findById(string $id, string $tenantId): ?UserEntity;
    public function findByEmail(string $email, string $tenantId): ?UserEntity;
    public function existsByEmail(string $email): bool;
}

Rule 2 — Commands and Results are final readonly

Every input to the Application layer is an immutable DTO. Every output is an immutable DTO. No arrays, no Eloquent models.
// app/Application/Identity/Commands/RegisterUserCommand.php

final readonly class RegisterUserCommand implements
    VersionedCommandInterface,
    IdempotentCommandInterface,
    SensitiveDataInterface
{
    public function __construct(
        public string $name,
        #[\SensitiveParameter] public string $email,
        #[\SensitiveParameter] public string $password,
        public ?string $tenantName = null,
        public ?string $slug = null,
        public ?string $tenantId = null,
        public ?string $role = null,
        public string $source = 'user_registration',
        public ?string $idempotencyKey = null,
        public int $schemaVersion = 1,
    ) {}
}
// app/Application/Identity/Results/RegisterUserResult.php

final readonly class RegisterUserResult
{
    public function __construct(
        public string $id,
        public string $tenantId,
        #[\SensitiveParameter] public string $email,
        public DateTimeImmutable $registeredAt,
        public bool $requiresVerification = true,
    ) {}
}

Rule 3 — Infrastructure implements, never decides

Eloquent models and repositories live exclusively in app/Infrastructure. Repositories receive and return Domain Entities — never raw Eloquent models.
// app/Infrastructure/Repositories/Sales/EloquentOrderRepository.php

final class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function save(OrderEntity $order): OrderEntity
    {
        // Eloquent is used here and only here
        $model = $order->id !== null
            ? Order::query()->find($order->id) ?? throw OrderNotFoundException::withId($order->id)
            : new Order;

        // ... persist attributes ...

        $model->save();

        // Map back to a Domain Entity before returning
        return $this->toEntity($model->fresh(['items', 'tenant']));
    }
}
Never return an Eloquent model from a repository. The Application layer must never see App\Models\*. Map everything to Domain Entities before crossing the boundary.

Rule 4 — Controllers are thin traffic cops

A controller’s only job is to translate an HTTP request into a Command, delegate to a Handler, and translate the Result into an HTTP response. No business logic, no validation logic, no SQL.
// app/Http/Controllers/Auth/RegisteredUserController.php

final class RegisteredUserController extends Controller
{
    public function __construct(
        private StatefulGuard $auth,
        private LoggerInterface $logger,
        private CommandBusInterface $bus
    ) {}

    public function store(RegisterTenantRequest $request): RedirectResponse
    {
        $command = new RegisterTenantCommand(
            businessName: $request->validated('business_name'),
            email: $request->validated('email'),
            password: $request->validated('password'),
            // ...
        );

        /** @var RegisterTenantResult $result */
        $result = $this->bus->dispatch($command);

        $user = \App\Models\User::where('email', $result->ownerEmail)->firstOrFail();
        $this->auth->login($user);

        return redirect()->route('filament.admin.pages.dashboard');
    }
}

The outbox pattern

Events are not dispatched inline inside transactions. Dispatching an event inside a DB transaction creates a race condition — the transaction could fail after the event fires, leaving listeners acting on data that was never committed. Instead, Vito uses a transactional outbox: the event is written to an outbox table inside the same transaction as the business data. A background worker (outbox:process, scheduled every minute) reads pending events and dispatches them after the transaction is confirmed committed.
// app/Infrastructure/Bus/TransactionalEventDispatcher.php

final readonly class TransactionalEventDispatcher implements EventDispatcherInterface
{
    public function __construct(
        private Dispatcher $dispatcher,
        private OutboxRepositoryInterface $outboxRepository
    ) {}

    public function dispatch(object $event): void
    {
        // Write to outbox table — atomic with the parent transaction
        // The background worker reads this and dispatches after commit
        $this->outboxRepository->append($event);
    }
}
This eliminates the “phantom write” race condition where the application process dies between a successful commit and an in-memory event dispatch.

TypeScript contract sync

PHP DTOs and Enums annotated with #[TypeScript] are compiled to TypeScript types. The generated file lives at resources/js/types/generated.d.ts.
# Run after any DTO or Enum change
php artisan typescript:transform

# Or via the npm alias
npm run types:sync
CI runs the transformer and then checks git diff --exit-code. If generated.d.ts has uncommitted changes, the build fails. You must run the sync locally before pushing.
This creates an explicit, machine-verified contract between the PHP backend and the TypeScript frontend. Type drift — where the backend changes a field and the frontend silently breaks — is impossible.

Build docs developers (and LLMs) love