This document explains how our Laravel projects are structured, including actions, DTOs, exceptions, services, interfaces, and pipelines, with examples.
app/
├─ Contracts/ # All interfaces
│ └─ ActionInterface.php
│
├─ Actions/
│ ├─ BaseAction.php
│ ├─ SaleOrderActions/ # Subfolder for SaleOrder related actions
│ │ ├─ CreateSaleOrderAction.php
│ │ ├─ UpdateSaleOrderAction.php
│ │ └─ CancelSaleOrderAction.php
│ └─ CustomerActions/
│ ├─ CreateCustomerAction.php
│ └─ UpdateCustomerAction.php
│
├─ Data/
│ ├─ BaseData.php
│ ├─ SaleOrderData/ # Subfolder for SaleOrder related DTOs
│ │ ├─ CreateSaleOrderData.php
│ │ └─ UpdateSaleOrderData.php
│ └─ CustomerData/
│ ├─ CreateCustomerData.php
│ └─ UpdateCustomerData.php
│
├─ Exceptions/
│ ├─ DomainException.php
│ └─ SaleOrderCancelledException.php
│
├─ Services/
│ ├─ PaymentGatewayService.php
│ └─ ShippingService.php
│
└─ Traits/
└─ ActionPipeline.php
- Location:
Contracts/ - Naming: end with
Interface - Example:
namespace App\Contracts;
interface ActionInterface
{
public function execute(): mixed;
}- Location:
Actions/ - BaseAction provides:
execute()→ runs inside a DB transaction by defaultwithoutTransaction()→ runs without transaction, useful for pipelines- Shared functionality like pipeline support
namespace App\Actions;
use Illuminate\Support\Facades\DB;
use App\Contracts\ActionInterface;
use App\Traits\ActionPipeline;
abstract class BaseAction implements ActionInterface
{
use ActionPipeline;
public function execute(): mixed
{
return DB::transaction(function () {
return $this->handle();
});
}
public function withoutTransaction(): mixed
{
return $this->handle();
}
abstract protected function handle(): mixed;
}namespace App\Actions\SaleOrderActions;
use App\Actions\BaseAction;
use App\Data\SaleOrderData\CreateSaleOrderData;
use App\Models\Order;
class CreateSaleOrderAction extends BaseAction
{
public function __construct(private CreateSaleOrderData $data) {}
protected function handle(): mixed
{
$order = Order::create([
'customer_id' => $this->data->customer->id,
'total' => $this->data->total,
]);
return $order;
}
}- Actions can be chained using
pipe()from ActionPipeline trait.
$order = app(ValidateSaleOrderAction::class)
->withoutTransaction()
->pipe(CreateSaleOrderAction::class)
->pipe(SendOrderConfirmationAction::class);- Each action gets the result of the previous action.
- Ensures modular, reusable workflows.
- Location:
Data/ - BaseData provides common functionality (e.g., to array conversion).
- Feature-specific DTOs live in subfolders, e.g.,
SaleOrderData.
namespace App\Data;
use Spatie\DataTransferObject\DataTransferObject;
abstract class BaseData extends DataTransferObject
{
public function toDbArray(): array
{
return (array) $this;
}
}namespace App\Data\SaleOrderData;
use App\Data\BaseData;
use App\Models\Customer;
class CreateSaleOrderData extends BaseData
{
public function __construct(
public Customer $customer,
public array $products,
public float $total
) {}
}- Use DTOs for actions with structured/multiple inputs.
- Skip DTOs for single-model actions.
- Location:
Exceptions/ - Custom exceptions always end with
Exception. - Optional base exception:
DomainException.
if ($saleOrder->isCancelled()) {
throw new SaleOrderCancelledException();
}Catching Exceptions:
try {
$action->execute();
} catch (DomainException $e) {
// Handle error in UI or API
}- Location:
Services/ - Purpose: Third-party API integrations
- Keep services stateless
- Inject services via constructor in actions
| Type | Suffix Example |
|---|---|
| Enum | StatusEnum |
| Interface | ReportInterface |
| Action | CreateInvoiceAction |
| Service | PaymentGatewayService |
| Observer | OrderObserver |
| Policy | UserPolicy |
| Controller | OrderController |
| Rule | UniqueEmailRule |
| Settings | GeneralSettings |
| State | OrderPendingState |
| DTO | OrderData |
| Exception | SaleOrderCancelledException |
| Test | OrderTest |
Note: In our project, we distinguish between the words state and status. We will always use "state" in models, actions, and DTOs.
- Test actions in isolation.
- Use Laravel container for instantiation.
- Mock services injected in constructors.
- Use DTOs for consistent input and Result DTOs for output.
- Test pipelines to ensure chaining works correctly.
- Keep logic in Actions/Services, not in controllers.
- Always pass models or DTOs, not raw IDs.
- Use custom exceptions for domain errors.
- Default
execute()uses DB transaction;withoutTransaction()for pipelines. - Pipelines help with modular and reusable workflows.
✅ This setup ensures a clean, testable, and scalable Laravel project, even as the project grows.