Modular Monolith with Clean Architecture.

Modular Monolith with Clean Architecture in Laravel
A practical implementation-level example for building a modular monolith using clean architecture in Laravel/PHP.
This document is written to be shared with engineers who want a concrete reference for structuring modules, separating business logic from frameworks, and keeping inter-module boundaries clean.
1. Goal
This architecture aims to achieve:
- one deployable application
- multiple business modules
- clear code ownership per module
- framework-independent domain logic
- controlled communication between modules
This is a modular monolith, not microservices.
That means:
- one Laravel application
- one deployment unit
- usually one database
- modules separated by code boundaries, not network boundaries
2. Core Architecture Rules
Rule 1: Keep business logic away from Laravel details
Business rules should not depend directly on:
- controllers
- requests
- Eloquent models
- facades
- route files
Rule 2: Modules talk through contracts
A module should not directly use another module's internal database models or repositories.
Use:
- interfaces
- gateways
- application contracts
- domain events when needed
Rule 3: Dependency direction goes inward
Presentation -> Application -> Domain
Infrastructure -> Application / Domain
The domain should be the most stable and framework-independent part.
3. Example Business Flow
We will model this use case:
Place Order Flow:
- User sends an order request
- Orders module validates input
- Orders use case builds the order
- Orders asks Inventory to reserve stock
- Inventory checks and decrements stock
- Orders saves the order
- API returns the created order
4. Suggested Folder Structure
app/
Modules/
Orders/
Domain/
Entities/
Order.php
OrderItem.php
Repositories/
OrderRepositoryInterface.php
Services/
InventoryGatewayInterface.php
Exceptions/
InsufficientStockException.php
Application/
DTOs/
PlaceOrderCommand.php
UseCases/
PlaceOrder.php
Infrastructure/
Persistence/
Eloquent/
Models/
OrderModel.php
OrderItemModel.php
Repositories/
EloquentOrderRepository.php
Providers/
OrdersServiceProvider.php
Presentation/
Http/
Controllers/
OrderController.php
Requests/
PlaceOrderRequest.php
Resources/
OrderResource.php
Routes/
api.php
Database/
Migrations/
2026_03_29_000001_create_orders_table.php
2026_03_29_000002_create_order_items_table.php
Factories/
Seeders/
Inventory/
Domain/
Repositories/
ProductStockRepositoryInterface.php
Application/
Services/
ReserveStockService.php
Infrastructure/
Persistence/
Eloquent/
Models/
ProductStockModel.php
Repositories/
EloquentProductStockRepository.php
Services/
StockReservationService.php
Providers/
InventoryServiceProvider.php
Database/
Migrations/
2026_03_29_000003_create_product_stocks_table.php
Shared/
Domain/
ValueObjects/
Money.php
Application/
Contracts/
ClockInterface.php
5. What Each Layer Does
Domain
Contains pure business concepts and rules.
Examples:
- entities
- value objects
- repository interfaces
- business exceptions
- gateway contracts
Domain should not know Laravel exists.
Application
Contains use cases.
Examples:
PlaceOrderCancelOrderRefundPayment
Application orchestrates business flow using domain contracts.
Infrastructure
Contains Laravel and persistence details.
Examples:
- Eloquent models
- repository implementations
- service provider bindings
- adapter implementations
Presentation
Contains delivery-layer code.
Examples:
- controllers
- form requests
- API resources
- routes
6. Orders Module Implementation
6.1 Domain Entity: Order
<?php
namespace App\Modules\Orders\Domain\Entities;
class Order
{
private ?int $id;
private int $userId;
private string $status;
/** @var OrderItem[] */
private array $items = [];
public function __construct(?int \(id, int \)userId, array \(items, string \)status = 'pending')
{
\(this->id = \)id;
\(this->userId = \)userId;
\(this->items = \)items;
\(this->status = \)status;
}
public static function create(int \(userId, array \)items): self
{
if (empty($items)) {
throw new \InvalidArgumentException('Order must have at least one item.');
}
return new self(
id: null,
userId: $userId,
items: $items,
status: 'pending',
);
}
public function confirm(): void
{
$this->status = 'confirmed';
}
public function getId(): ?int
{
return $this->id;
}
public function setId(int $id): void
{
\(this->id = \)id;
}
public function getUserId(): int
{
return $this->userId;
}
public function getStatus(): string
{
return $this->status;
}
/** @return OrderItem[] */
public function getItems(): array
{
return $this->items;
}
}
6.2 Domain Entity: OrderItem
<?php
namespace App\Modules\Orders\Domain\Entities;
class OrderItem
{
public function __construct(
private int $productId,
private int $quantity,
private int $unitPrice,
) {
if ($quantity < 1) {
throw new \InvalidArgumentException('Quantity must be at least 1.');
}
}
public function getProductId(): int
{
return $this->productId;
}
public function getQuantity(): int
{
return $this->quantity;
}
public function getUnitPrice(): int
{
return $this->unitPrice;
}
public function getSubtotal(): int
{
return \(this->quantity * \)this->unitPrice;
}
}
6.3 Repository Contract
<?php
namespace App\Modules\Orders\Domain\Repositories;
use App\Modules\Orders\Domain\Entities\Order;
interface OrderRepositoryInterface
{
public function save(Order $order): Order;
public function findById(int $id): ?Order;
}
6.4 Cross-Module Contract: Inventory Gateway
Orders should not directly use Inventory Eloquent models.
Instead, it depends on an abstraction.
<?php
namespace App\Modules\Orders\Domain\Services;
interface InventoryGatewayInterface
{
/**
* @param array<int, array{product_id:int, quantity:int}> $items
*/
public function reserve(array $items): void;
}
7. Orders Application Layer
7.1 Command DTO
<?php
namespace App\Modules\Orders\Application\DTOs;
class PlaceOrderCommand
{
/**
* @param array<int, array{product_id:int, quantity:int, unit_price:int}> $items
*/
public function __construct(
public readonly int $userId,
public readonly array $items,
) {}
}
7.2 Use Case: PlaceOrder
<?php
namespace App\Modules\Orders\Application\UseCases;
use App\Modules\Orders\Application\DTOs\PlaceOrderCommand;
use App\Modules\Orders\Domain\Entities\Order;
use App\Modules\Orders\Domain\Entities\OrderItem;
use App\Modules\Orders\Domain\Repositories\OrderRepositoryInterface;
use App\Modules\Orders\Domain\Services\InventoryGatewayInterface;
class PlaceOrder
{
public function __construct(
private OrderRepositoryInterface $orders,
private InventoryGatewayInterface $inventoryGateway,
) {}
public function execute(PlaceOrderCommand $command): Order
{
$items = array_map(
fn (array $item) => new OrderItem(
productId: $item['product_id'],
quantity: $item['quantity'],
unitPrice: $item['unit_price'],
),
$command->items
);
$order = Order::create(
userId: $command->userId,
items: $items,
);
$reservationItems = array_map(
fn (OrderItem $item) => [
'product_id' => $item->getProductId(),
'quantity' => $item->getQuantity(),
],
$items
);
\(this->inventoryGateway->reserve(\)reservationItems);
$order->confirm();
return \(this->orders->save(\)order);
}
}
Why this is clean
This use case knows nothing about:
- HTTP request objects
- controllers
- Eloquent
- facades
- route definitions
It only works with domain entities and contracts.
8. Inventory Module Implementation
8.1 Inventory Repository Contract
<?php
namespace App\Modules\Inventory\Domain\Repositories;
interface ProductStockRepositoryInterface
{
public function getAvailableQuantity(int $productId): int;
public function decrement(int \(productId, int \)quantity): void;
}
8.2 Application Service: ReserveStockService
<?php
namespace App\Modules\Inventory\Application\Services;
use App\Modules\Inventory\Domain\Repositories\ProductStockRepositoryInterface;
class ReserveStockService
{
public function __construct(
private ProductStockRepositoryInterface $stocks,
) {}
/**
* @param array<int, array{product_id:int, quantity:int}> $items
*/
public function execute(array $items): void
{
foreach (\(items as \)item) {
\(available = \)this->stocks->getAvailableQuantity($item['product_id']);
if (\(available < \)item['quantity']) {
throw new \RuntimeException(
"Insufficient stock for product {$item['product_id']}"
);
}
}
foreach (\(items as \)item) {
\(this->stocks->decrement(\)item['product_id'], $item['quantity']);
}
}
}
8.3 Adapter Exposed to Orders
Inventory implements the contract that Orders depends on.
<?php
namespace App\Modules\Inventory\Infrastructure\Services;
use App\Modules\Inventory\Application\Services\ReserveStockService;
use App\Modules\Orders\Domain\Services\InventoryGatewayInterface;
class StockReservationService implements InventoryGatewayInterface
{
public function __construct(
private ReserveStockService $reserveStockService,
) {}
public function reserve(array $items): void
{
\(this->reserveStockService->execute(\)items);
}
}
This is the clean boundary between modules.
9. Infrastructure Layer
9.1 Eloquent Models
<?php
namespace App\Modules\Orders\Infrastructure\Persistence\Eloquent\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class OrderModel extends Model
{
protected $table = 'orders';
protected $fillable = [
'user_id',
'status',
];
public function items(): HasMany
{
return $this->hasMany(OrderItemModel::class, 'order_id');
}
}
<?php
namespace App\Modules\Orders\Infrastructure\Persistence\Eloquent\Models;
use Illuminate\Database\Eloquent\Model;
class OrderItemModel extends Model
{
protected $table = 'order_items';
protected $fillable = [
'order_id',
'product_id',
'quantity',
'unit_price',
];
}
9.2 Repository Implementation
<?php
namespace App\Modules\Orders\Infrastructure\Persistence\Eloquent\Repositories;
use App\Modules\Orders\Domain\Entities\Order;
use App\Modules\Orders\Domain\Entities\OrderItem;
use App\Modules\Orders\Domain\Repositories\OrderRepositoryInterface;
use App\Modules\Orders\Infrastructure\Persistence\Eloquent\Models\OrderItemModel;
use App\Modules\Orders\Infrastructure\Persistence\Eloquent\Models\OrderModel;
use Illuminate\Support\Facades\DB;
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): Order
{
return DB::transaction(function () use ($order) {
$orderModel = OrderModel::create([
'user_id' => $order->getUserId(),
'status' => $order->getStatus(),
]);
foreach (\(order->getItems() as \)item) {
OrderItemModel::create([
'order_id' => $orderModel->id,
'product_id' => $item->getProductId(),
'quantity' => $item->getQuantity(),
'unit_price' => $item->getUnitPrice(),
]);
}
return new Order(
id: $orderModel->id,
userId: $orderModel->user_id,
items: array_map(
fn ($item) => new OrderItem(
productId: $item->product_id,
quantity: $item->quantity,
unitPrice: $item->unit_price,
),
$orderModel->items()->get()->all()
),
status: $orderModel->status,
);
});
}
public function findById(int $id): ?Order
{
\(model = OrderModel::with('items')->find(\)id);
if (! $model) {
return null;
}
return new Order(
id: $model->id,
userId: $model->user_id,
items: array_map(
fn ($item) => new OrderItem(
productId: $item->product_id,
quantity: $item->quantity,
unitPrice: $item->unit_price,
),
$model->items->all()
),
status: $model->status,
);
}
}
9.3 Inventory Stock Repository
<?php
namespace App\Modules\Inventory\Infrastructure\Persistence\Eloquent\Repositories;
use App\Modules\Inventory\Domain\Repositories\ProductStockRepositoryInterface;
use App\Modules\Inventory\Infrastructure\Persistence\Eloquent\Models\ProductStockModel;
class EloquentProductStockRepository implements ProductStockRepositoryInterface
{
public function getAvailableQuantity(int $productId): int
{
\(stock = ProductStockModel::where('product_id', \)productId)->first();
return $stock?->available_quantity ?? 0;
}
public function decrement(int \(productId, int \)quantity): void
{
\(stock = ProductStockModel::where('product_id', \)productId)->firstOrFail();
\(stock->decrement('available_quantity', \)quantity);
}
}
10. Presentation Layer
10.1 Request Validation
<?php
namespace App\Modules\Orders\Presentation\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PlaceOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'user_id' => ['required', 'integer'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_price' => ['required', 'integer', 'min:1'],
];
}
}
10.2 Controller
<?php
namespace App\Modules\Orders\Presentation\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Orders\Application\DTOs\PlaceOrderCommand;
use App\Modules\Orders\Application\UseCases\PlaceOrder;
use App\Modules\Orders\Presentation\Http\Requests\PlaceOrderRequest;
use App\Modules\Orders\Presentation\Http\Resources\OrderResource;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function store(
PlaceOrderRequest $request,
PlaceOrder $placeOrder,
): JsonResponse {
$command = new PlaceOrderCommand(
userId: $request->integer('user_id'),
items: $request->input('items'),
);
\(order = \)placeOrder->execute($command);
return response()->json([
'data' => (new OrderResource(\(order))->toArray(\)request),
], 201);
}
}
10.3 Resource
<?php
namespace App\Modules\Orders\Presentation\Http\Resources;
use App\Modules\Orders\Domain\Entities\Order;
use App\Modules\Orders\Domain\Entities\OrderItem;
class OrderResource
{
public function __construct(
private Order $order
) {}
public function toArray($request): array
{
return [
'id' => $this->order->getId(),
'user_id' => $this->order->getUserId(),
'status' => $this->order->getStatus(),
'items' => array_map(
fn (OrderItem $item) => [
'product_id' => $item->getProductId(),
'quantity' => $item->getQuantity(),
'unit_price' => $item->getUnitPrice(),
'subtotal' => $item->getSubtotal(),
],
$this->order->getItems()
),
];
}
}
10.4 Module Route File
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Orders\Presentation\Http\Controllers\OrderController;
Route::prefix('orders')->group(function () {
Route::post('/', [OrderController::class, 'store']);
});
11. Service Providers and Dependency Injection
11.1 Orders Service Provider
<?php
namespace App\Modules\Orders\Infrastructure\Providers;
use Illuminate\Support\ServiceProvider;
use App\Modules\Orders\Domain\Repositories\OrderRepositoryInterface;
use App\Modules\Orders\Domain\Services\InventoryGatewayInterface;
use App\Modules\Orders\Infrastructure\Persistence\Eloquent\Repositories\EloquentOrderRepository;
use App\Modules\Inventory\Infrastructure\Services\StockReservationService;
class OrdersServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepositoryInterface::class, EloquentOrderRepository::class);
$this->app->bind(InventoryGatewayInterface::class, StockReservationService::class);
}
public function boot(): void
{
$this->loadRoutesFrom(app_path('Modules/Orders/Presentation/Routes/api.php'));
$this->loadMigrationsFrom(app_path('Modules/Orders/Database/Migrations'));
}
}
11.2 Inventory Service Provider
<?php
namespace App\Modules\Inventory\Infrastructure\Providers;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Domain\Repositories\ProductStockRepositoryInterface;
use App\Modules\Inventory\Infrastructure\Persistence\Eloquent\Repositories\EloquentProductStockRepository;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
ProductStockRepositoryInterface::class,
EloquentProductStockRepository::class
);
}
public function boot(): void
{
$this->loadMigrationsFrom(app_path('Modules/Inventory/Database/Migrations'));
}
}
Why this matters
This is what makes the architecture real:
- the use case depends on interfaces
- the container provides implementations
- modules remain replaceable behind contracts
12. Database Migrations
12.1 Orders Table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('status');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
12.2 Order Items Table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('orders')->cascadeOnDelete();
$table->unsignedBigInteger('product_id');
$table->integer('quantity');
$table->integer('unit_price');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('order_items');
}
};
12.3 Product Stocks Table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('product_stocks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('product_id')->unique();
$table->integer('available_quantity')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('product_stocks');
}
};
13. Shared Folder: What It Is For
The Shared folder is for things that are:
- generic
- reusable across modules
- not owned by one business module
Good examples:
MoneyEmailClockInterfaceIdGeneratorInterface
Bad examples:
OrderHelperBillingCommonInventoryUtils- feature-specific services moved to shared just because two modules use them
Example Shared Value Object
<?php
namespace App\Modules\Shared\Domain\ValueObjects;
final class Money
{
public function __construct(
private int $amount,
private string $currency = 'USD'
) {}
public function add(Money $other): Money
{
if (\(this->currency !== \)other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new Money(\(this->amount + \)other->amount, $this->currency);
}
public function amount(): int
{
return $this->amount;
}
public function currency(): string
{
return $this->currency;
}
}
14. Request Flow Summary
For POST /orders:
OrderControllerreceives the requestPlaceOrderRequestvalidates input- Controller creates
PlaceOrderCommand PlaceOrderuse case executes- Use case builds domain
OrderandOrderItem - Use case calls
InventoryGatewayInterface - Laravel resolves it to
StockReservationService - Inventory reserves stock
- Orders repository persists the order
- Resource returns JSON response
This is a modular monolith because the whole flow runs inside one Laravel app.
This is clean architecture because the business flow does not depend on framework details.
15. What Not to Do
Bad: Use Eloquent directly in the use case
$order = OrderModel::create([...]);
This makes the application layer depend on Laravel ORM.
Bad: Access another module's internal models
ProductStockModel::where('product_id', $id)->decrement(...);
This breaks module boundaries because Orders is now touching Inventory internals.
Bad: Put business logic in controllers
foreach (\(request->items as \)item) {
// reserve stock
// calculate total
// create order
}
Controllers should adapt HTTP, not implement business rules.
16. Recommended Practical Rules
For a real Laravel modular monolith:
- keep Domain pure PHP
- keep Application focused on use cases
- keep Infrastructure responsible for Eloquent and framework integration
- keep Presentation thin
- let modules talk through interfaces
- keep
Sharedsmall and disciplined - place migrations inside modules and load them through service providers
17. Final Takeaway
A clean modular monolith should let you say:
- Orders can be understood mostly by reading the Orders module
- Inventory can change its persistence internals without breaking Orders
- Business rules do not depend on Laravel controllers or Eloquent
If those are true, the architecture is in a good place.




