DEV Community

Giovani Pessoa
Giovani Pessoa

Posted on • Edited on

PHP, Laravel, Clean Architecture and DDD

This is a sample project built with Laravel, designed to share knowledge about applying Domain-Driven Design (DDD) and Clean Architecture.

Domain-Driven Design (DDD) and Clean Architecture are complementary approaches. DDD focuses on modeling the business domain, aiming to establish a common language between developers and domain experts, which facilitates communication and understanding. Clean Architecture seeks to structure the application in layers, with the domain at the core. DDD helps define what the application does (the business logic), while Clean Architecture defines how the application is structured to do it, with a focus on dependency rules.

Let’s imagine an application for inventory control, where our first task is to register new products.

Since a domain shouldn't be linked to any specific framework, because one of the principles of Clean Architecture is to keep code portable, we’ll adopt a purist approach. Ideally, the domain layer should be placed in a top-level directory, outside the framework's default structure, such as inside a src/ folder. For example:

The app/ folder acting as Laravel’s 'interface' to the isolated domain.

In this way, and within the context of Clean Architecture and DDD, the domain remains completely isolated, and the app/ folder becomes Laravel’s "interface" to your domain. You need to install Laravel first, and then assess what needs to be created or moved.

What truly matters is the direction of dependencies, not the folder names where files are stored.

The only layer that should be aware of both your domain and the framework is the Infrastructure layer.

1 - Using your preferred IDE, create a new project using Laravel. If you're using VSCODE, open a New Terminal and type:
composer create-project laravel/laravel sample-project. Don't forget to enter the sample-project folder!

(optional) - In the example project, start the Laravel server:
php artisan serve

2 - At the root of the project, create a new src/ folder and inside it create the following structure below:

Plain Old PHP Object (POPO)

Note:

Adjust composer.json, as it need to know where the new classes are located.
Open the composer.json file and add the src/ folder to autoload.psr-4.

{
    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "AppCore\\": "src/"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, run composer dump-autoload to generate a new class map.

3 - Let's create the Eloquent Model **for our product. This model, which we will name **EloquentProductModel, will serve as the bridge to the database and will be used by our repository for all CRUD operations.

Run the command php artisan make:model Product -m because it creates the product model and the associated migration file. Artisan will create the Eloquent Product Model in the app/Models folder as Product.php and the migration file in database/migrations as YYYY_MM_DD_HHMMSS_create_products_table.php, something like this. After that, rename the generated Product.php model to EloquentProductModel.php and move it to the app/Infrastructure/Persistence/Eloquent folder.

4 - Read about Eloquent and Clean Architecture

5 - Customize the migration file created for the Product.

Open the file "YYYY_MM_DD_HHMMSS_create_products_table.php" in database/migrations and inside the up() method, add:

    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('description')->nullable();
            $table->decimal('price', 10, 2);
            $table->integer('stock')->default(0);
            $table->boolean('active')->default(1);
            $table->timestamps();
        });
    }
Enter fullscreen mode Exit fullscreen mode

Similarly, inside the down() method, add:

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
Enter fullscreen mode Exit fullscreen mode

6 - Important!

Before the next step, you need to set the database. You can use MySQL, PostgreSQL, or another non-relational database, such as MongoDB.

In this case, we're using PostgreSQL through AWS RDS.
You can search for "Using PostgreSQL on AWS RDS" or learn more in our article.

After getting the host, database, username and password, set these data in the .env file as below:

DB_CONNECTION=pgsql
DB_HOST=database.xxxxxxxxxxxx.us-east-2.rds.amazonaws.com
DB_PORT=5432 (default)
DB_DATABASE=database
DB_USERNAME=username
DB_PASSWORD=password
Enter fullscreen mode Exit fullscreen mode

Use the open file and change SESSION_DRIVER to "file." We'll explain more about this later.

# SESSION_DRIVER=database
SESSION_DRIVER=file
Enter fullscreen mode Exit fullscreen mode

After that, run the command php artisan migrate.

Up to here, congrats!

7 - Create and enrich the Product model.

Entities (Product) contain pure business logic, with no dependencies on Laravel.

Remember! The essence of clean architecture is independence. Your domain entity cannot inherit from any infrastructure class. Your Product class must be a simple POPO (Plain Old PHP Object).

  1. set validations for the properties
  2. set domain behavior methods
  3. set getters to access the properties
<?php

// src/Domain/Entities/Product.php
namespace AppCore\Domain\Entities;

use InvalidArgumentException;

class Product
{
    private ?int $id;
    private string $name;
    private ?string $description;
    private float $price;
    private int $stock;
    private bool $active;

    // set validations for the properties
    public function __construct(
        string $name,
        float $price,
        int $stock = 0,
        ?string $description = null,
        bool $active = true,
        ?int $id = null
    ) {
        if (empty($name)) {
            throw new InvalidArgumentException('Name is required');
        }
        if ($price < 0) {
            throw new InvalidArgumentException('Price must be greater than 0');
        }
        if ($stock < 0) {
            throw new InvalidArgumentException('Stock must be greater than 0');
        }

        $this->id = $id;
        $this->name = $name;
        $this->description = $description;
        $this->price = $price;
        $this->stock = $stock;
        $this->active = $active;
    }

    // set domain behavior methods
    public function decreaseStock(int $quantity): void
    {
        if ($this->stock < $quantity) {
            throw new InvalidArgumentException('Stock is not enough for the requested quantity');
        }
        $this->stock -= $quantity;
    }

    public function increaseStock(int $quantity): void
    {
        $this->stock += $quantity;
    }

    // set getters to access the properties
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function getPrice(): float
    {
        return $this->price;
    }

    public function getStock(): int
    {
        return $this->stock;
    }

    public function isActive(): bool
    {
        return $this->active;
    }
}
Enter fullscreen mode Exit fullscreen mode

8 - Create the Interface for the Product repository.

Inside the src/Application/Repositories folder, create a new file InterfaceProductRepository.php. This interface defines how the domain and use cases interact with the outside world.

Note:

By convention, it's common to suffix interfaces with "Interface".

<?php

// src/Application/Repositories/InterfaceProductRepository.php
namespace AppCore\Application\Repositories;

use AppCore\Domain\Entities\Product;

interface InterfaceProductRepository
{
    public function save(Product $product): void;
    public function findAll(): array;
    public function findById(int $id): ?Product;
    public function delete(Product $product): void;
}
Enter fullscreen mode Exit fullscreen mode

9 - Create the first use case to register the product.

Inside the src/Application/UseCases folder, create a new file RegisterProductUseCase.php. This use case will be responsible for creating the new product.

<?php

// src/Application/UseCases/RegisterProductUseCase.php
namespace AppCore\Application\UseCases;

use AppCore\Application\Repositories\InterfaceProductRepository;
use AppCore\Application\DTO\RegisterProductData;
use AppCore\Domain\Entities\Product;

class RegisterProductUseCase
{
    public function __construct(private InterfaceProductRepository $productRepository) {}

    public function execute(RegisterProductData $data): Product
    {
        $product = new Product(
            $data->name,
            $data->description,
            $data->price,
            $data->stock,
            true
        );

        $this->productRepository->save($product);
        return $product;
    }
}
Enter fullscreen mode Exit fullscreen mode

10 - Create the Eloquent for the Product repository.

Inside the app/Infrastructure/Persistence folder, create a new file named EloquentProductRepository.php. This file will implement all the methods of InterfaceProductRepository.

Notes:

By nomenclature, you can start the file name using "Eloquent". Do you know Eloquent? It is an ORM (Object-Relational Mapper) that allows you to interact with your database using object-oriented syntax. It's similar to other ORMs like "Doctrine".
[Read about Eloquent]

(https://laravel.com/docs/5.0/eloquent)

Remember that the methods of an Eloquent model can be used to perform operations such as querying data, inserting new data, updating records, deleting records, and establishing relationships with other models. However, in this case, we need to create an Eloquent model for the Product that will serve as a bridge to the database.

Some methods must receive the POPO domain entity as an argument so that the repository can translate the entity into an Eloquent model.

You may use a different structure here, but the logic will be the same. The methods used to insert new data and update records, for example, must receive the POPO domain entity as an argument so that the repository can translate the entity.

Before implementing Eloquent for the Product Repository, let's create Eloquent for the Product model. Inside the app/Infrastructure/Persistence/Eloquent folder, create a new file named EloquentProductModel.php and implements as below:

<?php

// app/Infrastructure/Persistence/Eloquent/EloquentProductModel.php
namespace App\Infrastructure\Persistence\Eloquent;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class EloquentProductModel extends Model
{
    // set the factory to use the model
    use HasFactory;

    // set the table name
    protected $table = 'products';

    // set the fillable fields
    protected $fillable = [
        'name',
        'description',
        'price',
        'stock',
        'active'
    ];
}
Enter fullscreen mode Exit fullscreen mode

After that we can implement Eloquent for the Product Repository.

<?php

// app/Infrastructure/Persistence/EloquentProductRepository.php
namespace App\Infrastructure\Persistence;

use AppCore\Application\Repositories\InterfaceProductRepository;
use App\Infrastructure\Persistence\Eloquent\EloquentProductModel;
use AppCore\Domain\Entities\Product;

class EloquentProductRepository implements InterfaceProductRepository
{
    public function save(Product $product): void
    {
        // the domain entity is received as an argument
        // the repository translates this entity to an Eloquent model
        $eloquentModel = $product->getId()
            ? EloquentProductModel::find($product->getId())
            : new EloquentProductModel();

        $eloquentModel->name = $product->getName();
        $eloquentModel->description = $product->getDescription();
        $eloquentModel->price = $product->getPrice();
        $eloquentModel->stock = $product->getStock();
        $eloquentModel->active = $product->isActive();

        $eloquentModel->save();
    }

    public function findAll(): array
    {
        $eloquentModels = EloquentProductModel::all();
        $products = [];

        foreach ($eloquentModels as $eloquentModel) {
            $products[] = new Product(
                $eloquentModel->name,
                $eloquentModel->price,
                $eloquentModel->stock,
                $eloquentModel->description,
                $eloquentModel->active,
                $eloquentModel->id
            );
        }

        return $products;
    }

    public function findById(int $id): ?Product
    {
        $eloquentModel = EloquentProductModel::find($id);

        if (!$eloquentModel) {
            return null;
        }

        return new Product(
            $eloquentModel->name,
            $eloquentModel->price,
            $eloquentModel->stock,
            $eloquentModel->description,
            $eloquentModel->active,
            $eloquentModel->id
        );
    }

    public function delete(Product $product): void
    {
        EloquentProductModel::destroy($product->getId());
    }
}
Enter fullscreen mode Exit fullscreen mode

11 - When creating a Laravel project, we noticed that it suggests an AppServiceProvider.php file within app/Providers.

Remember that we made a small adjustment to the app folder structure to suit the project's needs. As we're adopting a custom structure, we'll need to move the **AppServiceProvider.php **file from its default location (app/Providers) to our new app/Infrastructure/Providers folder. If the original app/Providers folder is now empty, you can safely delete it.

Important! The Laravel Service Container queries the AppServiceProvider and creates the instance of the concrete class. EloquentProductRepository implements the methods of the InterfaceProductRepository, which defines the contract for Product persistence.

<?php

// app/Infrastructure/Providers/AppServiceProvider.php
namespace App\Infrastructure\Providers;

use AppCore\Application\Repositories\InterfaceProductRepository;
use App\Infrastructure\Persistence\EloquentProductRepository;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // bind interface to its concrete implementation
        $this->app->bind(InterfaceProductRepository::class, EloquentProductRepository::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

Note:

You need to change the class path of the application's service provider. Instead of App\Providers\AppServiceProvider::class to App\Infrastructure\Providers\AppServiceProvider::class. Open the bootstrap/providers.php file and do it.

<?php

return [
    App\Infrastructure\Providers\AppServiceProvider::class,
];
Enter fullscreen mode Exit fullscreen mode

12 - Let's use mocks for InterfaceProductRepository in the use case testing units.

Inside tests folder, create the following structure: tests/Unit/Application/UseCases/RegisterProductUseCaseTest.php.

Open **RegisterProductUseCaseTest.php **file and implements:

<?php

namespace Tests\Unit\Application\UseCases;

use PHPUnit\Framework\TestCase;
use AppCore\Application\UseCases\RegisterProductUseCase;
use AppCore\Application\Repositories\InterfaceProductRepository;
use AppCore\Domain\Entities\Product;
use Mockery;

class RegisterProductUseCaseTest extends TestCase
{
    /**
     * @var InterfaceProductRepository|Mockery\MockInterface
     */
    protected $productRepositoryMock;

    protected function setUp(): void
    {
        parent::setUp();
        $this->productRepositoryMock = Mockery::mock(InterfaceProductRepository::class);
    }

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    /**
     * test if the use case saves the product successfully
     *
     * @return void
     */
    public function testExecuteSavesProductSuccessfully()
    {
        $productData = [
            'name' => 'Caneta',
            'description' => 'Caneta Azul',
            'price' => 2.50,
            'stock' => 10,
        ];

        $this->productRepositoryMock->shouldReceive('save')
            ->once()
            ->withArgs(function (Product $product) use ($productData) {
                return $product->getName() === $productData['name'] &&
                    $product->getPrice() === $productData['price'];
            });

        $useCase = new RegisterProductUseCase($this->productRepositoryMock);
        $product = $useCase->execute($productData);

        $this->assertInstanceOf(Product::class, $product);
        $this->assertEquals($productData['name'], $product->getName());
    }
}
Enter fullscreen mode Exit fullscreen mode

Important! In the terminal, run the command ./vendor/bin/phpunit tests/Unit/Application/UseCases/RegisterProductUseCaseTest.php.

Notes:

The setUp() method runs automatically before each test method in your class. Its main purpose is to prepare the test environment, ensuring that each test starts from scratch, with a clean and consistent state. It creates a mock object of your repository interface.

The tearDown() method does the opposite of setUp(): it runs automatically after each test method in your class. Its main purpose is to clean up the test environment so that the next test starts in a completely clean state.

13 - IMPORTANT! FROM HERE, WE'LL WORK WITH THE OUTER LAYER (WE CAN CALL IT "PRESENTATION LAYER"). WE WON'T CREATE A NEW FOLDER NAMED UI, BECAUSE WE HAVE SPECIFIC RESOURCES FOR IT.

Inside the routes folder, open the web.php file. This file allows us to define all the routes for the web application.

For our CRUD scenario, the most efficient way to create routes is using Route::resource. It generates seven default routes for a resource (index, create, store, show, edit, update _and _destroy).

Change its contents to:

<?php

use App\Http\Controllers\ProductController;

use Illuminate\Support\Facades\Route;

Route::resource('/products', ProductController::class);
Enter fullscreen mode Exit fullscreen mode

14 - Using Terminal, type php artisan make:controller ProductController --resource to Laravel server creates a new Controller inside the app/Http/Controllers folder. About the --resource argument, it can create a new Controller with all the standard CRUD methods index(), create(), store(), show(), edit(), update() and destroy().

See below the ProductController logical usage map based on dependency inversion. The Dependency Inversion Principle (DIP) states that low-level models should depend on high-level models.

logical usage map

Open the ProductController.php file and implements its structure as below:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use AppCore\Application\UseCases\RegisterProductUseCase;

class ProductController extends Controller
{
    /**
     * display a listing of the resource.
     */
    public function index()
    {
        //
    }

    /**
     * show the form for creating a new resource.
     */
    public function create()
    {
        return view('products.Register');
    }

    /**
     * store a newly created resource in storage.
     */
    public function store(Request $request, RegisterProductUseCase $registerProductUseCase)
    {
        try {
            $request->validate([
                'name' => 'required|string|max:255',
                'description' => 'nullable|string',
                'price' => 'required|numeric|min:0',
                'stock' => 'required|integer|min:0',
            ], [
                'name.required' => 'Name is require',
                'price.required' => 'Price is required',
                'price.numeric' => 'Price must be a number',
                'stock.required' => 'Stock is required',
                'stock.integer' => 'Stock must be an integer',
            ]);

            $registerProductUseCase->execute($request->all());

            return redirect()->route('products.index')
                ->with('success', "Product created successfully!");
        } catch (\Illuminate\Validation\ValidationException $e) {
            return redirect()->back()
                ->withErrors($e->errors())
                ->withInput();
        } catch (\InvalidArgumentException $e) {
            // domain's exceptions are handled here
            return redirect()->back()
                ->withErrors(['general' => $e->getMessage()])
                ->withInput();
        } catch (\Exception $e) {
            // this is a generic exception
            return redirect()->back()
                ->withErrors(['general' => $e->getMessage()])
                ->withInput();
        }
    }

    /**
     * display the specified resource.
     */
    public function show(string $id)
    {
        //
    }

    /**
     * show the form for editing the specified resource.
     */
    public function edit(string $id)
    {
        //
    }

    /**
     * update the specified resource in storage.
     */
    public function update(Request $request, string $id)
    {
        //
    }

    /**
     * remove the specified resource from storage.
     */
    public function destroy(string $id)
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

Notes:

$request->validate acts as a "gatekeeper": if the data matches, it opens the door. Otherwise, it rejects the request and returns it to its origin, with an explanation of why. You might consider using DTOs (Data Transfer Objects) or richer Form Requests to decouple validation logic from the controller and keep it clean.

The Laravel Service Container injects the RegisterProductUseCase instance into the ProductController's store method.

Download the repository

Conclusion and next steps

We've reached the end of this guide, and using our sample application, we've demonstrated how DDD and Clean Architecture can be applied to a Laravel project. This approach helps us create a well-organized and maintainable structure. Our code is now more decoupled, business logic is safeguarded within the application's core, and swapping out external components, such as the database, has become far simpler. This enables us to build applications that are more robust, testable, and scalable.

Deepening the architecture: Additional yips and practices

The journey doesn't end here. To take your architecture to the next level, consider these practices that complement what we've learned:

Testing the outer layers

While we focused on unit testing the Application layer (the Use Cases), it's crucial to ensure the outer layers also function as expected. Create integration tests for your Infrastructure layer, especially for the repositories. This guarantees that communication with the database is correct and that the mapping between the domain entity and the Eloquent model works perfectly.

The role of mappers

To keep the repository class clean and focused, we can extract object conversion logic into dedicated classes. A Mapper is a class responsible for translating data from one layer to another. For example, a ProductMapper could be used to convert a Product domain entity into an EloquentProductModel (and vice versa). This centralizes the mapping logic, preventing it from being scattered throughout your code and ensuring the repository is responsible solely for coordinating persistence, not for data conversion.

The power of the Service Provider

All the magic of Dependency Injection in our project happens thanks to Laravel's Service Container, orchestrated by the Service Provider. It's the "hero" that resolves dependencies and delivers the concrete class (like EloquentProductRepository) whenever the interface (our ProductRepositoryInterface) is requested. Remember, you can use the bind or singleton methods in the Service Provider to register any service in your application, not just repositories.

Next step: Refactoring the Controller

In our ProductController, data validation is still handled directly within the method. While this approach is functional, in a system that adheres to the single responsibility principle, we can do better! The ideal is for the controller to focus solely on orchestrating the data flow, while the validation logic resides in its own dedicated place.

In my next article, we will delve deeper into the Presentation layer and refactor our code to use Form Requests and Data Transfer Objects (DTOs). This will decouple the validation process and make our controller even cleaner and more focused on its primary task.

Top comments (1)

Collapse
 
xwero profile image
Info Comment hidden by post author - thread only accessible via permalink
david duymelinck • Edited

Imagine the building your house... What is the purpose and how each room connects.

We should stop comparing software with building houses. rooms can be changed. You can make a bathroom an kitchen and visa versa.
Whatever can pass the door opening will pass.
Software has layers but they are not even close to floors in a house. They are more houses next to each other with connecting doors.

you need to create the structure that will reflect the DDD layers

DDD doesn't force you to use a directory hierarchy. The main idea is to have a common language when addressing a certain topic. In one domain you can say tags and in another you can say category, but they could both mean a flat list of terms to group items.

Move the Product model from app/Models to app/Domain/Entities

Models don't go in the domain, because they are tied to Laravel.
A domain should not have ties to a framework, because one of the other ideas of DDD/clean architecture is to have code that is portable. If you want to use an other framework the domain code should not change.

Inside the app/Application/Repositories directory, create a new file IProductRepository.php. This interface defines how the domain and use cases interact with the outside world.

Two things here:

  • You are contradicting yourself because you place the class in the application layer, while in the next sentence you mention it is in the domain. The domain in the right layer
  • Don't use single character prefixes, or suffixes, if you want the technical name in the filename write it in full. Other developers that will read the code will thank you.

By nomenclature, you can start the file name using "E" because it represents the first letter the name "Eloquent".

By that logic the model should also have the prefix, because that is the part that is even closer related to Eloquent than the repository.

If you want to teach people, please understand what you are talking about.
I hope my comment lets you digg deeper in DDD and clean architecture to find out what the theories want to achieve.

Some comments have been hidden by the post's author - find out more