How to organize communication between domain entities in Laravel

How to organize communication between domain entities in Laravel

In developing projects, I often adhere to the architectural pattern of Porto. I am attracted by the simplicity of its organization and the availability of a ready-made solution on Laravel. Its essence is to move all business logic to Actions, and smaller features to Tasks, and transfer interaction with the database from Repositories. Ultimately, the project is easy to scale, easy to test, and adding new functionality is much faster.

An example of the implementation of this pattern can be found in my github.

I will not describe the Porto device in too much detail in this article, all information can be easily found in the documentation.

In this article, I will touch on one very interesting topic – how to organize interaction between modules.

As an example, let's take the latest version 9 of Laravel and move the domain layer to the src folder. This is what the composer.json file looks like:

 

    "autoload": {

        "psr-4": {

            "App\\": "app/",

            "Database\\Factories\\": "database/factories/",

            "Database\\Seeders\\": "database/seeders/",

            "Domains\\": "src/Domains",

            "Parents\\": "src/Parents"

        }

    },

 

Let's create two domains: Product and Category. For beginning, only two folders in src folder. For them, we need to register Service Providers. Here is the content of CategoryServiceProvider file:

 

<?php

 

namespace Domains\Category\Providers;

 

use Illuminate\Support\ServiceProvider;

 

class CategoryServiceProvider extends ServiceProvider

{

    public function register()

    {

    }

 

    public function boot()

    {

        $this->registerRoutes();

    }

 

    /**

     * @return void

     */

    protected function registerRoutes(): void

    {

        $this->loadRoutesFrom(

            path: __DIR__ . '/../Routes/Api/v1.php',

        );

    }

}

 

Similarly, we create the file ProductServiceProvider. Don't forget to add these classes to the config/app.php file in the providers block.

Migrations for these two modules can also be found on github.

Next, we add all the basic functionality to these modules. I will briefly describe their structure using the Category module as an example. All operations with the database take place in the Repositories folder. Here are the contents of the CategoryRepository file:

 

<?php

 

declare(strict_types=1);

 

namespace Domains\Category\Repositories\Eloquent;

 

use Domains\Category\Models\Category;

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Illuminate\Pagination\LengthAwarePaginator;

use Parents\Repositories\QueryBuilder;

use Parents\Repositories\Repository;

 

final class CategoryRepository extends Repository implements CategoryRepositoryInterface

{

    public function all(): LengthAwarePaginator

    {

        return QueryBuilder::for(Category::class)->jsonPaginate();

    }

 

    public function getCategoryBySlug(string $slug): Category

    {

        /** @var Category $category */

        $category = QueryBuilder::for(Category::class)->where('slug', $slug)->firstOrFail();

        return $category;

    }

}

 

For correct json responses I prefer to use json:api standart. For this, passes perfectly spatie packages: spatie/laravel-query-builder, spatie/laravel-json-api-paginate, spatie/laravel-fractal.

To implement the main business logic for getting a list of categories, we create an Action that will receive the necessary data from the repository (GetAllCategories file):

 

<?php

declare(strict_types=1);

 

namespace Domains\Category\Actions;

 

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Parents\Actions\Action;

 

final class GetAllCategories extends Action

{

    public function __construct(

        protected CategoryRepositoryInterface $repository

    )

    {

    }

 

    public function handle(): \Illuminate\Pagination\LengthAwarePaginator

    {

        return $this->repository->all();

    }

}

 

All that remains for us now is to transfer the Action to the controller:

 

<?php

declare(strict_types=1);

 

namespace Domains\Category\Http\Controllers\Api\V1;

 

use App\Http\Controllers\Controller;

use Domains\Category\Actions\GetAllCategories;

use Domains\Category\Transformers\CategoryTransformer;

use League\Fractal\Serializer\JsonApiSerializer;

 

final class CategoryController extends Controller

{

    public function index(): ?array

    {

        return fractal(

            GetAllCategories::run(),

            app(CategoryTransformer::class),

            new JsonApiSerializer()

        )->withResourceName('Category')

            ->toArray();

    }

}

 

We can register our routes in file src/Domains/Category/Routes/Api/v1.php:

 

<?php

declare(strict_types=1);

 

use Illuminate\Support\Facades\Route;

 

Route::prefix('api/v1')->as('api:v1:')->group(function () {

 

    Route::prefix('categories')->as('categories:')->group(function () {

        Route::get('/', [\Domains\Category\Http\Controllers\Api\V1\CategoryController::class, 'index'])->name('index');

    });

 

});

 

So, we have the following structure for this module:

  1. The request gets into the file with the routing in our module directory, which we moved to a separate file through ServiceProviders.
  2. Routing takes us to the controller, which is located in the Http/Controllers folder.
  3. The controller redirects us to Actions, where all business logic is stored.
  4. If the business logic is large, then we break it down into tasks. To do this, create separate files in the Tasks folder. But since we don't have complex logic at the moment, we can do without this layer.
  5. Action does not interact directly with the database. Only through the Repository. Note that we are accessing the repository through an interface, not directly. This is done so that, if necessary, it would be more convenient to work with tests and switch to another database.
  6. The response is returned to the controller, which formats it through the fractal package into the json:api standart.

So, we have two domains – Product and Category. They are currently isolated from each other. Except that there are system links between these two domains in models. As a rule, I don’t pay much attention to them, since they are located in one place and are easily administered.

But let's imagine that we want to add additional connectivity. For example, we need to get an array of products from category by their id, and from products we would like to get a category model by its slug.

If we make direct connections between Actions or Tasks in our modules, then our project will dramatically increase coupling. And this is terrible for the following reasons:

  1. There is no way to write isolated tests. They begin to affect third-party modules.
  2. There is a risk of additional bugs. When one component of the system is impacted, it can affect another.
  3. Large labor costs in scaling and administration.
  4. Higher refactoring costs. We do not know how the system will behave when the category module is replaced or corrected.

Therefore, in my opinion, the best option for organizing such connectivity between modules is to make sure that all interaction goes through the central hub of modules.

I saw one of the solutions to this problem in the Spryker framework and I quickly transferred this idea to my projects.

So let's look at the first scenario. We want to get a category from the Product module by its slug. Currently, we already have a link in the model between the category and the product, but it is made by id. And we have a requirement—to get its model by slug.

We have the corresponding method in the CategoryRepository. Let's write an Action for it (GetCategoryBySlug.php):

<?php

declare(strict_types=1);

 

namespace Domains\Category\Actions;

 

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Parents\Actions\Action;

 

final class GetCategoryBySlug extends Action

{

    public function __construct(

        protected CategoryRepositoryInterface $repository

    )

    {

    }

 

    public function handle(string $slug): \Domains\Category\Models\Category

    {

        return $this->repository->getCategoryBySlug($slug);

    }

}

 

As already described above, calling this Action from the components of the Product module is a horrible idea. Let's centralize all interaction. Firstly, let's clearly show what methods we make available to other modules. To achieve this, create a Facade (CategoryFacade file):

 

<?php

 

declare(strict_types=1);

 

namespace Domains\Category\Facades;

 

use Domains\Category\Actions\GetCategoryBySlug;

use Domains\Category\Models\Category;

 

final class CategoryFacade extends \Parents\Facades\Facade implements CategoryFacadeInterface

{

    public function getCategoryBySlug(string $slug): Category

    {

        return GetCategoryBySlug::run($slug);

    }

}

 

All it does is call our Action. As you can see, it implements a separate interface. If necessary, we can add other methods there, but we do not need them yet. Don't forget to register the interface-to-class association in the RepositoryServiceProvider.

 

<?php

 

declare(strict_types=1);

 

namespace Parents\Providers;

 

use Domains\Category\Facades\CategoryFacade;

use Domains\Category\Facades\CategoryFacadeInterface;

use Domains\Category\Repositories\CategoryRepositoryInterface;

use Domains\Category\Repositories\Eloquent\CategoryRepository;

use Domains\Product\Facades\ProductFacade;

use Domains\Product\Facades\ProductFacadeInterface;

use Domains\Product\Repositories\Eloquent\ProductRepository;

use Domains\Product\Repositories\ProductRepositoryInterface;

use Illuminate\Support\ServiceProvider;

 

class RepositoryServiceProvider extends ServiceProvider

{

    public function register(): void

    {

        $this->app->bind(ProductRepositoryInterface::class, ProductRepository::class);

        $this->app->bind(CategoryRepositoryInterface::class, CategoryRepository::class);

        $this->app->bind(CategoryFacadeInterface::class, CategoryFacade::class);

        $this->app->bind(ProductFacadeInterface::class, ProductFacade::class);

    }

}

 

And we need to register it in config/app.php.

Now all we have to do is connect to this facade from the Product module. How will we do it? There is a DependencyProvider pattern for this. Create file src/Domains/Product/Providers/ProductDependencyProvider.php:

 

<?php

declare(strict_types=1);

 

namespace Domains\Product\Providers;

 

use Domains\Category\Facades\CategoryFacade;

use Domains\Category\Facades\CategoryFacadeInterface;

 

final class ProductDependencyProvider extends \Parents\Providers\DependencyProvider

{

    public function getCategoryFacade(): CategoryFacadeInterface

    {

        return app(CategoryFacadeInterface::class);

    }

}

 

Yes, all we did in it was just add a method in which we create a CategoryFacade instance. Now, anywhere in our system, we can get a category through the provider. For example, through Action in the folder with the Product module (file src/Domains/Product/Actions/GetCategoryBySlug.php):

 

<?php

declare(strict_types=1);

 

namespace Domains\Product\Actions;

 

use Domains\Category\Models\Category;

use Domains\Product\Providers\ProductDependencyProvider;

use Parents\Actions\Action;

 

final class GetCategoryBySlug extends Action

{

    public function __construct(

        protected ProductDependencyProvider $dependencyProvider

    )

    {

    }

 

    public function handle(string $slug): Category

    {

        return $this->dependencyProvider->getCategoryFacade()->getCategoryBySlug($slug);

    }

}

 

But for this, it is no longer necessary to create a separate Action, it is enough to call it from the controller or any other part of the system. An important fact remains that all methods and all points of interaction are centralized with us. If desired, you can remove the links between these modules in the model and transfer them to facades and providers as well, for greater isolation. But so far, you do not need it in my practice.

So, we have done the first task, move on to the second. We get an array of products from the categories by their id.

First step, create a facade (src/Domains/Product/Facades/ProductFacade.php):

<?php

 

declare(strict_types=1);

 

namespace Domains\Product\Facades;

 

use Domains\Product\Actions\GetProductsByIds;

 

final class ProductFacade extends \Parents\Facades\Facade implements ProductFacadeInterface

{

    public function getProductsByIds(array $ids): \Illuminate\Database\Eloquent\Collection|array

    {

        return GetProductsByIds::run($ids);

    }

}

 

Here we also call Action, where we get a list of products from the repository.

Create the src/Domains/Category/Providers/CategoryDependencyProvider.php file, where we register the connection with the Product module:

 

<?php

 

declare(strict_types=1);

 

namespace Domains\Category\Providers;

 

use Domains\Product\Facades\ProductFacade;

use Domains\Product\Facades\ProductFacadeInterface;

 

final class CategoryDependencyProvider extends \Parents\Providers\DependencyProvider

{

    public function getProductFacade(): ProductFacadeInterface

    {

        return app(ProductFacadeInterface::class);

    }

}

 

And now our file src/Domains/Category/Transformers/CategoryTransformer.php looks like this:

 

<?php

declare(strict_types=1);

 

namespace Domains\Category\Transformers;

 

use Domains\Category\Models\Category;

use Domains\Category\Providers\CategoryDependencyProvider;

use Domains\Product\Transformers\ProductTransformer;

use League\Fractal\Resource\Collection;

use Parents\Transformers\Transformer;

 

final class CategoryTransformer extends Transformer

{

    public function __construct(

        protected CategoryDependencyProvider $dependencyProvider

    )

    {

    }

 

    protected array $defaultIncludes = [];

 

    protected array $availableIncludes = ['products'];

 

    public function transform(Category $category): array

    {

        return [

            'id' => (int) $category->id,

            'name' => $category->name,

            'slug' => $category->slug,

            'created_at' => $category->created_at,

            'updated_at' => $category->updated_at,

            'products_count' => $this->dependencyProvider->getProductFacade()->getProductsByIds([2,3])->count(),

        ];

    }

 

    public function includeProducts(Category $category): Collection

    {

        return $this->collection($category->products, new ProductTransformer);

    }

}

 

Here, of course, the example is not realistic, it is given only for a general understanding that we can call the facade from anywhere in the system. In the includeProducts method, we get a collection of products through a model association. If we go to the facade, then this method will look like this:

 

    public function includeProducts(Category $category): Collection

    {

        return $this->collection($this->dependencyProvider->getProductFacade()->getProductsByCategoryId($category->id), new ProductTransformer);

    }

 

The main thing to remember is that facades should not contain any business logic. All the facade has to do is call an Action or, in the worst case, a Repository. But not more.

So, in this article, we looked at two aspects, how to implement a DDD approach in Laravel and how to control the relationship between domain entities.

 

Popular Posts

My most popular posts

Maximum productivity on remote job
Business

Maximum productivity on remote job

I started my own business and intentionally did my best to work from anywhere in the world. Sometimes I sit with my office with a large 27-inch monitor in my apartment in Cheboksary. Sometimes I’m in the office or in some cafe in another city.

Hello! I am Sergey Emelyanov and I am hardworker
Business PHP

Hello! I am Sergey Emelyanov and I am hardworker

I am a programmer. I am an entrepreneur in my heart. I started making money from the age of 11, in the harsh 90s, handing over glassware to a local store and exchanging it for sweets. I earned so much that was enough for various snacks.

Hire Professional CRM developer for $25 per hour

I will make time for your project. Knowledge of Vtiger CRM, SuiteCRM, Laravel, and Vue.js. I offer cooperation options that will help you take advantage of external experience, optimize costs and reduce risks. Full transparency of all stages of work and accounting for time costs. Pay only development working hours after accepting the task. Accept PayPal and Payoneer payment systems. How to hire professional developer? Just fill in the form

Telegram
@sergeyem
Telephone
+4915211100235