Domain Driven Design with Laravel
Domain Driven Design with Laravel
DESIGN WITH
LARAVEL
The only design approach you need
MARTIN JOO
Martin Joo - Domain-Driven Design with Laravel
Basic Concepts
Domain-Driven Design
Working With Data
Value Objects
Data Transfer Objects
Repositories
Custom Query Builders
Services
Actions
ViewModels
CQRS
States And Transitions
Domains And Applications
Advantages And Disadvantages
Designing an E-mail Marketing Software
Overview
Subscribers
Broadcasts
Sequences
Automations
Other Features
Why E-mail Marketing?
User Stories
Data Modeling
Subscribers
Broadcasts
SentMails
A Quick Note On Performance
Sequences
Automations
Domains
Building an E-mail Marketing Software
Setting Up Domains and Applications
Subscribers
Creating A New Subscriber
Updating A Subscriber
1 / 327
Martin Joo - Domain-Driven Design with Laravel
View Models
Vue Component
Get Subscribers
The Real Power of DTOs and Actions
Conclusion
Broadcasts
The Broadcast DTO
Handling Filters
Upserting A Broadcast
Filtering Subscribers
Sending A Broadcast
Calculating The Performance Of A Broadcast
Previewing A Broadcast
Get Broadcasts
Sequences
Creating A Sequence
Proceeding A Sequence
Refactoring
Updating The Subscriber's Status
Calculating The Performance Of A Sequence
Progress Of A Sequence
Dashboard And Reports
New subscriber counts
All-Time Performance
Subscribers
Automations
Upserting automations
Running automations
Conclusion
Thank You
2 / 327
Martin Joo - Domain-Driven Design with Laravel
Basic Concepts
Domain-Driven Design
First of all, we have to answer the most obvious question: what is Domain-Driven Design.
DDD is a software development approach that tries to bring the business language and the
code as close together as possible. This is the most critical attribute of this approach. But
for some reason, DDD is one of the most misunderstood and overcomplicated topics in the
developer community, so I'll try to make it easy to understand.
Strategic Design
Technical Design
In my honest opinion, strategic design is way more important than the technical aspects.
It's hard to summarize it in one cool sentence, but you will see what I mean in the rest of the
book. For now, these are the essential pillars:
Domains and namespaces. Later, I'll talk about what a domain is, but DDD teaches us
to structure our code very expressive and logical.
Choosing the proper names. For example, if the business refers to the users as
"customers" or "employees, " you should rename your User class to follow that
convention.
The classes and objects should express the intention behind them. In the most simple
Laravel application, you have models and controllers. What do you think when you see
a project with 50 models and 50 controllers? You can see the application's primary
domain, but you have to dig deeper if you want to have a good understanding of the
features, right? Now, what about 300 models and 500 controllers? You have no chance
to reason about this kind of application.
In most projects, developers prefer technical terms over business concepts. That's natural.
After all, we're technical people. But I have a question: are those technical terms
significant?
3 / 327
Martin Joo - Domain-Driven Design with Laravel
Let me show you an example. This is a snippet from one of my applications I wrote on Oct
28, 2016, after finishing the Design Patterns book. Take a look at it (it's not Laravel):
class Search_View_Container_Factory_Project
{
/**
* @var Search_View_Container_Relation_Project
!"
private static $_relationContainer;
/**
* @param array $data
* @return Search_View_Container_Project
!"
public static function createContainer(array $data)
{
if ($data['current'] !# 'complex') {
return self!$createComplex($data);
} else {
return self!$createSimple($data);
}
}
/**
* @param array $data
* @return Search_View_Container_Project
!"
private static function createSimple(array $data)
{
$container = new Search_View_Container_Project('simple');
4 / 327
Martin Joo - Domain-Driven Design with Laravel
$container!%setSearchTerm(
Arr!$get($data, 'search_term')
);
$relationContainer = new
Search_View_Container_Relation_Project();
$industryModel = new Model_Industry();
$industries = $industryModel!%getAll();
$relationContainer!%addItem(
$industryItem,
Search_View_Container_Relation_Item!$TYPE_INDUSTRY
);
}
$container!%setRelationContainer($relationContainer);
return $container;
}
/**
5 / 327
Martin Joo - Domain-Driven Design with Laravel
self!$$_relationContainer!%addItem($item, $type);
}
}
}
Today it's February 2, 2022. What do you think? After six years, do I have any clue what the
heck is a Search_View_Container_Relation_Item? No, I have no idea what it is. I only know
one thing for sure: it does not help me. This project is about freelancers and projects. This
class does something with searching projects (I guess), but it does not reveal that intention.
Did you ever hear a product manager saying: wow, we got so much positive feedback on
the Search View Container Factory Project feature?
Maybe if I take a step back and look at the file structure, I have a better idea.
6 / 327
Martin Joo - Domain-Driven Design with Laravel
Technical terms and overused patterns suck when it comes to high-level business
applications.
So strategic design is all about not building projects like this one. And technical design
gives you some valuable tools to achieve that. In the following pages, we'll talk about these
concepts:
Value Objects
7 / 327
Martin Joo - Domain-Driven Design with Laravel
8 / 327
Martin Joo - Domain-Driven Design with Laravel
No type-hints
Undocumented structure
No restrictions. You can put product models, product IDs, and product arrays under the
same key.
Associative arrays are big unstructured blobs of data. Don't get me wrong; they can be
helpful but very annoying at the same time. Initially, PHP arrays tried to solve every
problem: queues, stacks, lists, hash maps, and trees. Everything. But with its weak type
system, it's tough to maintain this kind of data structure.
If you think about it, data plays a huge role in any business application:
So you have to work with data in every single layer of your application. Fortunately, Laravel
and DDD give us some very clever concepts.
9 / 327
Martin Joo - Domain-Driven Design with Laravel
Value Objects
Value Object is an elementary class that contains mainly (but not only) scalar data. So it's a
wrapper class that holds together related information. Let's see an example:
class Percent
{
public readonly ?float $value;
public readonly string $formatted;
10 / 327
Martin Joo - Domain-Driven Design with Laravel
This class represents a percentage value. This simple class gives you three advantages:
It encapsulates the logic that handles null values and represents them as percentages.
You always have two decimal places (by default) in your percentages.
Better types.
An important note: business logic or calculation is not part of a value object. The only
exception I make is basic formatting.
You take a float value and make it a first-class citizen using a Percent value object. You
don't have to worry about anymore if a method in your app returns a formatted string or a
float number. Every percentage value can be expressed as a Percent object from now on.
So you know, it contains the float number and the formatted string value.
What else can be expressed as a value object? Almost anything, to name a few examples:
Addresses. In an e-commerce application where you have to deal with shipping, it can
be beneficial to use objects instead of strings. You can express each part of an address
as a property:
11 / 327
Martin Joo - Domain-Driven Design with Laravel
City
ZIP code
Line 1
Line 2
Numbers. Any financial application can benefit from using value objects when
calculating metrics or comparing numbers. You can express some very high-level
concepts, for example, Margin.
class Margin
{
public function !&construct(
public readonly float $topLine,
public readonly float $bottomLine,
public readonly float $margin,
){}
}
Suppose you've worked with financial applications that deal with publicly traded
companies. You know that a number like revenue is given in millions (or billions in some
cases, for example, the market cap). So when you query Apple's revenue (which is 378
billion at the time of writing) from a finance API, you don't get 378,323,000,000 but 378,323,
so we can express it in the code as well:
12 / 327
Martin Joo - Domain-Driven Design with Laravel
class Margin
{
public function !&construct(
public readonly Millions $topLine,
public readonly Millions $bottomLine,
public readonly Percent $margin,
) {}
}
class MetricsService
{
public function profitMargin(IncomeStatement
$incomeStatement): Margin
{
return new Margin(
topLine: $incomeStatement!%revenue,
bottomLine: $incomeStatement!%net_profit,
margin: new Percent(
$incomeStatement!%net_profit!%value /
$incomeStatement!%revenue!%value
),
);
}
}
13 / 327
Martin Joo - Domain-Driven Design with Laravel
In this example, I assume that revenue and netProfit are instances of Millions . But
isn't IncomeStatement an Eloquent model? Glad you asked. It is. And we can write a
custom cast to convert floats to Millions :
/**
* @param Millions $millions
!"
public function set($model, $key, $millions, $attributes) {
return [
$key !( $millions!%value,
];
}
}
When you're accessing an attribute on the model, the get method will be called. So
$incomeStatement->revenue will return an instance of Millions .
When you're setting an attribute on the model, the set method will be called. So
$incomeStatement->revenue = new Millions(1000) will insert the value property
(1000) from the Millions instance.
14 / 327
Martin Joo - Domain-Driven Design with Laravel
protected $casts = [
'revenue' !( MillionsCast!$class,
'net_profit' !( MillionsCast!$class,
];
So, in a nutshell, this is how you use a value object. To summarize it:
By using value objects, you can make objects from cohesive scalar data
In the introduction, I wrote that data is a crucial part of any application. I gave you this list:
As you can see in the cast and the other examples, a value object is mainly used inside (but
not exclusively!) our application. In the next chapter, we'll discuss what happens at the
boundaries (requests and responses).
15 / 327
Martin Joo - Domain-Driven Design with Laravel
return $course;
}
}
This is an oversimplified example, of course. I'm working on an e-learning system, and you
can believe me, the request for creating a new course is overwhelming.
16 / 327
Martin Joo - Domain-Driven Design with Laravel
After time this action will become more complicated, and you want to refactor it. Let's move
this into a service (later, we'll talk about services in more detail. For now, it's a class that
implements some business logic):
class CourseService
{
public function create(array $data): Course
{
$course = Course!$create($data);
$this!%createLessons($course, $data['lessons']);
$this!%addStudents($course, $data['student_ids']);
return $course;
}
17 / 327
Martin Joo - Domain-Driven Design with Laravel
$course!%students()!%attach($studentId);
}
}
}
My biggest problem is that I don't want to maintain and debug massive associative
arrays five years later.
The above example is very basic. Now, please imagine your favorite legacy project, where
you have to work with methods like this:
/**
* You have to reverse-engineer this whole shit-show
* just to get an idea about the shape of $data, right?
!"
}
DTOs can solve this problem by structuring your unstructured data. The same
CourseService with DTOs:
18 / 327
Martin Joo - Domain-Driven Design with Laravel
class CourseService
{
public function create(CourseData $data): Course
{
$course = Course!$create($data!%all());
$this!%createLessons($course, $data!%lessons);
$this!%addStudents($course, $data!%student_ids);
return $course;
}
/**
* @param Collection<LessonData> $lessons
!"
public function createLessons(
Course $course,
Collection $lessons
): void {
foreach ($lessons as $lessson) {
!) $lesson is an instance of LessonData
$course!%lessons()!%create($lesson);
}
}
19 / 327
Martin Joo - Domain-Driven Design with Laravel
}
}
}
Now, instead of arrays, we have objects like CourseData and LessonData . Let's take a
look inside CourseData :
class CourseData
{
public function !&construct(
public readonly int ?$id,
public readonly string $title,
public readonly string $description,
/** @var Collection<LessonData> !"
public readonly Collection $lessons,
/** @var Collection<int> !"
public readonly Collection $student_ids,
) {}
20 / 327
Martin Joo - Domain-Driven Design with Laravel
collect($data['student_ids']),
);
}
}
The only place you have to deal with arrays with this approach is the DTO itself. Only the
factory function will know anything about the ugly $data array. Every layer of your
application will use a structured, type-hinted object.
As you can see, this class does not interact with Request or any other class that is
environment-dependent so that you can use DTOs anywhere, including:
Controllers
Console Commands
Services or Actions (covered later in the book)
Models or Query Builders (covered later in the book)
21 / 327
Martin Joo - Domain-Driven Design with Laravel
I think that's a much better approach, especially in larger projects. But now, we have
another problem. Just imagine how many classes we need to create to store a course:
CreateCourseRequest
CourseData
CourseResource
LessonData
LessonResource
A few value objects here and there
And in this example, we have only two models! What if the domain model of this feature is
much more complex? You can quickly end up with 10-15 classes to implement the CRUD
functionality for courses. It's not the end of the world, but it can be very frustrating.
Fortunately, we have an elegant solution. But first, let's summarize what a DTO is:
Enter the laravel-data package by Spatie. You can use one DTO to act as a:
Important note: If you want to use DTOs you don't need to go with laravel-data. You can
write pure PHP objects, and you'll do just fine. But I find this package so helpful; I cannot
imagine a large project without it.
22 / 327
Martin Joo - Domain-Driven Design with Laravel
The basics are very similar to a pure PHP DTO, but we have this Lazy thing. I will talk
about it later, but it's very similar to the whenLoaded method used in Laravel resources (it
helps us avoid N+1 query problems). So we have a subscriber with a nested TagData
collection and a nested FormData property.
This package can create a DTO from a request automatically. Since it can be used as a
request, we can define validation rules:
23 / 327
Martin Joo - Domain-Driven Design with Laravel
We can also specify how we want the package to create a SubscriberData from an HTTP
request:
24 / 327
Martin Joo - Domain-Driven Design with Laravel
You can see that I run some DB queries to get the tags and the form. So instead of IDs (that
come from the request), I have models which can map to TagData or FormData . Later I'll
explain these things in more detail.
You can inject any Data class, and transformation from the request will happen
automatically! And as you can see, a Data object can be returned from a controller action
and will be cast to JSON (including the nested properties).
In my opinion, it's a fantastic tool to have, so I'll use it heavily in the demo application later.
One more question to end up this chapter: what's the difference between value objects and
DTOs?
That's the main difference. However, in the Laravel community, I often see people mixing up
the two concepts. You can even see people writing only value objects, and they use it as a
DTO and as a VO.
25 / 327
Martin Joo - Domain-Driven Design with Laravel
Of course, these rules are essential, but I don't see them as strict rules that you have to
follow if you want DDD. I rather see them as guidelines. For example, later in the demo
application, I will use a DTO instead of a value object because it's much more convenient in
the given situation.
26 / 327
Martin Joo - Domain-Driven Design with Laravel
Repositories
Now that we've learned about data, we can move on to the database. Where to put your
queries? How to structure your code? We've all asked these questions, and here's the
answer: no one knows precisely. It's different for every project and every team.
One way to organize your queries is by using repositories. It's a controversial topic in the
Laravel and PHP community. I won't be using them in the demo application, but it's a DDD
concept and, to be honest, not a bad one. At the end of this chapter, I'll show you the
Laravel equivalent of the repository pattern.
class ProductRepository
{
public function create(ProductData $data): Product
{
Product!$create($data!%all());
!) Other database related code
}
27 / 327
Martin Joo - Domain-Driven Design with Laravel
!%get();
}
}
It's a class for your database queries. That's it. So instead of writing your queries inside
your controllers or models, you move them into a repository. Usually, each model has a
repository, but that's not necessarily true every time.
So it seems like a good concept; why are they being hated? There are several problems:
Usually, each (or the vast majority of) model has a repository, such as the
ProductRepository in this example. So you're literally just moving a query from one
place to another, from the model to the repository. After six months of development, the
ProductRepository will become a 5000 lines monster. Just like the Product model
would have become a 5000 lines monster.
By definition, when using repository classes, every database query should end up in
one of them. So you need to move single-use queries as well. You'll quickly end up with
28 / 327
Martin Joo - Domain-Driven Design with Laravel
You don't need to have one repository for every model. Let's imagine you have a big
enterprise project with 200+ tables. One "module" or feature set is a very basic issue
tracker. This feature set requires only six tables and 500 lines of database-related code.
You don't have to spread these 500 lines of code in 6 repositories (as you would do
with models). You can write only one class called IssueTrackerRepository . You
cannot do that with models. I think it can be helpful in some situations.
To be honest, that's the only benefit I can think of. If you're already familiar with DDD, you
probably heard something like this: The repository pattern abstracts the data store and
enables you to replace your database without changing your business code. That's true.
However, in the last decade, I've faced a lot of strange feature requests, but two of them
never came up:
So I'm not authentic to talk about it as an advantage. That being said, I don't think
repositories are helpful in most situations. In fact, Laravel has a better solution: custom
query builders.
29 / 327
Martin Joo - Domain-Driven Design with Laravel
$query = Product!$where(
'name',
'Domain-Driven Design with Laravel'
);
We call the where method on the Model classes. So there has to be some connection
between Model and Builder . This connection is the newEloquentBuilder in the base
Model class:
/**
* Create a new Eloquent query builder for the model.
*
* @param \Illuminate\Database\Query\Builder $query
* @return \Illuminate\Database\Eloquent\Builder|static
!"
public function newEloquentBuilder($query)
{
return new Builder($query);
}
30 / 327
Martin Joo - Domain-Driven Design with Laravel
It returns a new Builder instance, and every time you interact with your model, this
instance will be used. So when you call Product::where the newEloquentBuilder
method will be called, it returns a new Builder .
We can extend this base Builder class and can create a custom query builder:
This is a custom builder for the Mail model. The whereOpened method can be used as a
scope. In fact, model scopes are just syntactic sugar. So this method can be used like this:
Mail!$whereOpened()!%get();
However, you don't have to write scope-like methods. In a query builder, you can do
(almost) anything:
31 / 327
Martin Joo - Domain-Driven Design with Laravel
$dividendThisMonth =
DividendPayout!$sumByDate(DateFiler!$thisMonth());
This method cannot be chained because it returns a float. The last piece of the puzzle is to
instruct Laravel that we want to use our query builder. This can be done by overwriting the
newEloquentBuilder method in your model:
32 / 327
Martin Joo - Domain-Driven Design with Laravel
And that's it! A query builder is the Laravel equivalent of a repository if you think about
it. But in my opinion, it feels much more like Laravel. To sum it up:
This way, models remain very simple and thin. Later I will discuss where to put the single-
use queries.
33 / 327
Martin Joo - Domain-Driven Design with Laravel
Services
A service class means a lot of different things for many developers. But in general: it's a
class that holds some business logic. For example:
class TodoNotificationService
{
public function sendDueTodayNotifications(): void
{
Todo!$whereDueToday()
!%get()
!%each(fn (Todo $todo) !( $todo!%user!%notify(
new DueTodayNotification($todo)
));
}
}
Services are often used to wrap some external services, such as Github, Twilio, and others.
But they can be more low-level, something like this:
class TodoService
{
public function create(TodoData $data): Todo
{
$todo = Todo!$create($data!%all());
!)!!*
}
}
34 / 327
Martin Joo - Domain-Driven Design with Laravel
So services can be used as repositories? In theory, no, but in practice, yes. And a lot of
developers use services instead of repositories.
Can services be used together with repositories? Yes, but I don't recommend this
approach.
When you have repositories and services as well in a project, this is the main idea:
This approach is fine and can work, but here's the main problem:
You end up with inconsistent classes. When you're working with the Todo model, you
have a repository and a service. But when you're working on a Project related
feature, you only have a repository because the project doesn't require a service class.
And in the case of Todo , you won't be able to tell quickly if a method lives in a service
or a repository.
So, in general, your features are spread across services and repositories and a bit
harder to reason about.
This isn't very objective, so it may work very well for you! As for me, I stick with actions.
35 / 327
Martin Joo - Domain-Driven Design with Laravel
Actions
What if we can combine repositories and services? We probably will have a big mess, like a
fat controller that does everything, right? Yeah, it's true. But let's forget about technical
responsibilities for a minute. In the first chapter, I wrote that technical code sucks in a
business application. I think that's why I dislike repositories and services being used
alongside each other.
But what if we specify the responsibility based on features instead of technical stuff? Now,
we have a clean, reusable action class:
class CreateTodoAction
{
public function execute(TodoData $data): Todo
{
$todo = Todo!$create($data!%all());
if (!$todo!%creator!%is($todo!%assignee)) {
$todo!%assignee!%notify(
new TodoAssignedNotification($todo)
);
}
return $todo;
}
}
36 / 327
Martin Joo - Domain-Driven Design with Laravel
Single responsibility. Each action takes care of precisely one thing, like creating a todo.
Self-contained. An action completes a task from beginning to end.
Nesting. An action can call any other action. If creating a todo is a complex task, maybe
it's a good idea to extract the notification logic.
Queueable. Not by default, but with the help of Spatie's laravel-queueable-action
package.
But here's the biggest one: your actions describe your user stories. So they bring
your code closer to the business language. Just take a look at this:
37 / 327
Martin Joo - Domain-Driven Design with Laravel
If a new developer joins your team, he/she will know exactly what your application can do
with subscribers:
Create a subscriber
Filter subscribers
Import subscribers
There's no need to dig deep into controllers, models, or services. It's clean and
straightforward. In my opinion, it's a huge win. As a bonus, actions are widely used in the
Laravel community. By the way, these example come from the sample application we'll
build throughout the book. As you can see, it's going to be a lot of action.
There's one more detail we can discuss. There are three ways to write functions in an
action.
This is the most obvious choice, and you cannot go wrong with it. I used this in the previous
examples.
Invokable class
class CreateTodoAction
{
public function !&invoke(TodoData $data): Todo
{
!) !!*
}
}
38 / 327
Martin Joo - Domain-Driven Design with Laravel
class TodoController
{
public function store(
Request $request,
CreateTodoAction $createTodoAction
) {
$todo = $createTodoAction(TodoData!$from($request));
}
}
As you can see, an action can be called just like a function. This is also a good option
because actions look different from any other class, so you immediately know this is an
action.
However, there are cases when you cannot inject the action into a method but only into a
constructor. For example, when you're using an action in another one:
class CreateTodoAction
{
public function !&construct(
private readonly NotifyUsersAction $notifyUsersAction
) {}
39 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see, you need to wrap the action in parentheses. And I don't particularly
appreciate how it looks, so generally, this is not my go-to approach.
class CreateTodoAction
{
public static function execute(TodoData $data): Todo
{
!) !!*
}
}
class TodoController
{
public function store(Request $request)
{
$todo = CreateTodoAction!$execute(
TodoData!$from($request)
);
}
}
40 / 327
Martin Joo - Domain-Driven Design with Laravel
This is my favorite one because it looks just neat! But if you want to write tests that mock
actions, it's a poor choice because you cannot do that easily (or maybe it's impossible, I'm
not sure). However, it's not a problem because I write API tests, so I don't mock actions. I
only mock external dependencies, such as 3rd party APIs. In this book, I'll go with this
approach, and you'll see how clean the code looks.
You can be wrong if you think actions are not part of DDD and are just some modern
"Laravel magic." I first heard about actions from Robert C. Martin in his book "Agile
Software Development," written in 2002 (10 years before Laravel was born). In this book,
they were called "transactions." After that, I ran into them again in the C# world, where they
were called "commands." By the way, this is the first letter in CQRS (more on that later).
After all that, the MediatR package was created (2015), where actions were called
"requests." As you can see, actions have a long history, and the Laravel community did not
invent them. However, "action" is the coolest name I've ever heard to describe a class.
41 / 327
Martin Joo - Domain-Driven Design with Laravel
ViewModels
ViewModel is a very clever way to handle view-related data. But in this context, I'm not
talking about Blade views exclusively. You can think of a view model as a data container
responding to a specific request. They can be used in both SPAs (Inertiajs included) and
full-stack MVC applications.
Let's say we work on a report page where we need to show revenue-related data,
something like:
Total revenue
Total number of customer
The average revenue per customer
This example can use a (highly simplified) view model like this:
42 / 327
Martin Joo - Domain-Driven Design with Laravel
A view model implements (or calls from a query builder) every query that the page or
response needs. It can be used in controllers like this:
But how can we produce a JSON response from a class with methods only? The base
ViewModel class implements Laravel's Arrayable interface and will return an array like
this:
[
'total_revenue' !( 24500,
'total_number_of_customers' !( 2311,
'average_revenue_per_customer' !( 10.60,
]
43 / 327
Martin Joo - Domain-Driven Design with Laravel
So a method called totalRevenue becomes a total_revenue array key, and Laravel will
convert this array to JSON. Later I'll show the exact code that does the magic (5 lines using
Reflection).
Wait a minute! Earlier, you said we're going to use DTOs as responses, didn't you? Yes,
that's true, so in the sample application, instead of int values, I'll use DTOs. A quick
example:
thisWeek: Subscriber!$whereSubscribedBetween(
DateFilter!$thisWeek()
)!%count(),
thisMonth: Subscriber!$whereSubscribedBetween(
DateFilter!$thisMonth()
)!%count(),
total: Subscriber!$count(),
);
}
}
44 / 327
Martin Joo - Domain-Driven Design with Laravel
This is a dashboard page, and as you can see, I pack numbers together into a DTO, and I
return this DTO from the ViewModel. The response looks like this:
{
"new_subscribers_count": {
"today": 4,
"this_week": 39,
"this_month": 104,
"total": 341
}
}
Your code is one step closer to the domain language. So when a product manager says
"on the dashboard page," you immediately know that they talk about the
GetDashboardViewModel .
It can be an excellent addition to have the exact structure of your UI expressed as
classes.
Later in the sample application, we will use Inertia but also APIs, so you will see how to
write these classes for both situations (spoiler: the same way).
Now that we've learned about actions and view models, we can move on to the mysterious
CQRS.
45 / 327
Martin Joo - Domain-Driven Design with Laravel
CQRS
CQRS is one of the most overcomplicated and misunderstood aspects of DDD, in my
opinion, so let's clarify it! It means Command and Query Responsibility Segregation. It's a
complicated name; let's see what those words mean:
class ProductService
{
public function create(ProductData $data): Product
{
!)!!*
}
I don't think there is anything wrong with this class; in fact, it can be the superior solution
in many projects.
So how do we apply CQRS? If you think about the previous sections, we have similar
concepts to commands and queries. The only difference is the name:
46 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see, it's very, very easy to apply CQRS by using these two classes. As it turned
out, I was using CQRS years before I even knew what this thing was. So, I don't push the
idea of CQRS; I like working with actions and view models. Also, I don't think it's necessary
for every application, but it gives you some excellent benefits:
So that's what CQRS is all about. However, you'll find some very hard-to-understand
articles and tutorials if you search for them. They often show you how CQRS is used with
other more complicated concepts such as event sourcing, event stores, and separate read
and write databases.
In this book, I won't talk about event sourcing for three reasons:
I'm not using it, so I'm not authentic to write about it.
I don't think it's necessary for most business applications.
It requires a whole different architecture and perspective.
So it's doubtful that you will start to refactor your application to event sourcing after
learning about it. However, it's much simpler to adapt any other concepts we'll use in this
book.
CQRS can be a bit more complex (and usually is) than I described it. For example, in the C#
world, developers use a so-called mediator. It's a way to implement in-process
synchronous messaging. Basically, it's a layer that handles commands and queries. If you
want to learn more about it, check out this package.
Also, CQRS is a very different (and complex) animal in the microservices world. It means
you have separate services and databases for reading and writing. In this context, it's
almost required to use CQRS and event sourcing and event-driven async architecture
together. If you're interested in this topic, check out this video (you can watch it without a
master's degree in CS).
47 / 327
Martin Joo - Domain-Driven Design with Laravel
Draft
Pending
Paid
PaymentFailed
For the sake of simplicity, let's say the most critical business logic about the Order's state is
whether it can be changed or not. A customer can modify a draft Order but cannot modify a
Paid order.
Each state extends the OrderStatus parent class. Now we can create these concrete
classes:
48 / 327
Martin Joo - Domain-Driven Design with Laravel
To make it even better, we can write an Enum that has a factory function and can create
these status classes:
49 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see, this enum acts like a factory function. This is one of the hidden features of
PHP8.1 enums. In the Order model, we can leverage this with an attribute accessor:
50 / 327
Martin Joo - Domain-Driven Design with Laravel
This is the new Laravel 8 accessor syntax; it's equivalent to this one:
First, I create an enum from the string value stored in the database; after that, I call the
factory on the Enum. So anytime you access the status attribute of an order, you get an
OrderStatus instance.
51 / 327
Martin Joo - Domain-Driven Design with Laravel
!) Instead of:
$order!%status!%canBeChanged();
52 / 327
Martin Joo - Domain-Driven Design with Laravel
However, it can be a significant overhead if you have only a few states in your model and
it's only used to check some basic behavior.
Moving on, we need to change the state from Pending to Paid at some point. I think we all
wrote code similar to this in the past:
Domain-Driven Design teaches us the following: we have to treat these transitions as first-
class citizens. So let's put them in dedicated classes! First, we can create some kind of
abstraction. In this case, we don't need a class, only an interface:
53 / 327
Martin Joo - Domain-Driven Design with Laravel
interface Transition
{
/**
* @throws Exception
!"
public function execute(Order $order): Order;
}
A Transition can be executed. It takes an Order and returns an Order. If something goes
wrong it throws an Exception. That's the contract.
$order!%status = PendingOrderStatus!$class;
$order!%save();
return $order;
}
}
First, it makes sure that the current Order is Draft and updates it to Pending. Also, you can
use PHP8 enum together with states and transitions.
54 / 327
Martin Joo - Domain-Driven Design with Laravel
As I said earlier, it can be beneficial if you have complicated logic based on states and
transitions. However, it can be overengineering and introduce unwanted complexity in your
codebase. For that reason, I only use them when I need to deal with really complicated
state-related logic.
55 / 327
Martin Joo - Domain-Driven Design with Laravel
Laravel is a great framework and a good starting point for beginners. By default, it
structures your code based on technical details. I’m talking about this:
56 / 327
Martin Joo - Domain-Driven Design with Laravel
This is a tiny application, but you can see the code that handles Departments is spread
across six different places. It’s grouped by technical attributes:
Controllers
Models
Requests
Resources
Actions
DTOs
Imagine a project with 200 models, 300 actions, 500 controllers, and 500+ routes. After a
while, it becomes a nightmare. If you need to introduce a new attribute to the department
model and accept it in your API, you probably have to change five files in six different
directories.
By the way, have you ever heard a stakeholder saying something like: “Please work a little
bit on the DepartmentController and add a new attribute to the DepartmentResource”? Of
course not. They say things like: “On the department page, I need to see the number of
employees within the department.” And from that information, you know that you probably
need to tweak the DepartmentController and the DepartmentResource.
So, in my opinion, it is a good idea if the code is similar to the business language and
follows the same structure. By using domains, we can achieve a much better structure:
57 / 327
Martin Joo - Domain-Driven Design with Laravel
The main benefit is that if you work on a broadcast-related feature, you go into the
Broadcast folder, and everything is there. Meanwhile, your co-worker can work in the
Subscriber domain. It also makes onboarding easier, in my opinion.
As you can see, there is no HTTP or Controllers folder inside a Domain. That's intentional. A
domain contains only business code. The subscriber folder contains everything you can do
with a subscriber. It does not have any application-related code. By application, I mean:
API
Web app
Console commands
These environment-related classes live inside the app folder, just like in any Laravel app.
58 / 327
Martin Joo - Domain-Driven Design with Laravel
59 / 327
Martin Joo - Domain-Driven Design with Laravel
Data
Value Objects
Data Transfer Objects
Working with data
Services
CRQS
Actions
View models
States and transitions
Code structure
Domains
Applications
If you think about them, these are straightforward ideas. Except for states and transitions,
there's not even a "pattern" or a class structure behind these concepts. They take an idea
(like structuring unstructured data with DTOs) and give us some classes that help us
achieve the goal clean. This brings us to the most important advantage:
The technical aspects of DDD are easy. We, developers, make it more complicated
than it should be.
But these simple classes and ideas come with disadvantages as well:
There are a lot of different concepts and jargon associated with DDD. This is why it's
often misunderstood, and people make it more complicated than it should be.
Also, at the code level, we're going to use a lot of different classes. It can be hard to get
used to it if you're writing fat controllers and models.
As a bonus, if you're browsing DDD articles or videos online, they are often written in a
60 / 327
Martin Joo - Domain-Driven Design with Laravel
In my view, these concepts are not strict rules. So it's not like "you either use them
100% correct, or you're not event doing DDD." I don't think that's a good attitude.
For example, if you're confused about DTOs and value objects, pick one of them and
put everything under that folder. It's okay. You won't be outcasted from the developer
community.
This book aims to help you write better software instead of blindly following some rules that
don't fit in Laravel.
Important note: there are other DDD concepts such as aggregates and aggregate roots
that we won't use in this book. The reason is simple: I don't think they are helpful with
Laravel.
61 / 327
Martin Joo - Domain-Driven Design with Laravel
Overview
At its core, an e-mail marketing application is software that stores your subscribers, and
you can send e-mails to them. But it also comes with a great set of more complex features,
such as:
Managing subscribers.
Tagging them. Tags are helpful to filter subscribers when you want to send e-mails.
Sending broadcast (or one-time) e-mails. You write the content, set the filters, and hit
the Send button.
Creating sequences. A sequence is a collection of e-mails delivered to subscribers after
a certain delay. For example, you write four e-mails, and you want to send these e-
mails in four weeks. You can create a sequence that does precisely that automatically.
And the great thing about sequences is that they handle new subscribers as well. So if
you create this sequence in February and a person subscribes in May, they will still be
added to it and get one e-mail per week.
Adding subscribers to sequences based on specific criteria. We can also create
different filters. For example, we want to exclude subscribers who bought a particular
product or have a specific tag.
Creating subscription forms where people can enter their e-mail addresses. You can
build an HTML form with an e-mail input that can be embedded into your site. If
someone submits this form, they will be added to your e-mail list.
Tracking e-mail opens, and link clicks.
Generating reports from these metrics.
And much more, but these are the core features of ConvertKit.
62 / 327
Martin Joo - Domain-Driven Design with Laravel
In the upcoming pages, I'll discuss these features in more detail, and after that, we're ready
to write some user stories and design the domain model of our own ConvertKit Clone.
Subscribers
Subscribers represent people in an e-mail list. Usually, a subscriber has an e-mail address
and other custom fields, like first name, last name, etc. A subscriber can be added to a list
in several different ways:
Manually
By submitting a subscription form
Via CSV import
We'll cover all of these use cases in our application; however, I won't implement a fully
responsive works-on-every-device form builder because it's out of the scope of this book
(and I'd probably lose the fight between me and CSS). By the way, a form looks like the one
from my blog:
Subscribers can also have tags. Tags can be used to filter out people from a particular e-
mail. Let's say you have an e-mail list that contains people who haven't opened your e-
mails in the last six months (you can get this statistic from ConvertKit). First, you query
them and mark them with an inactive tag. Later, you can write an e-mail where you try to re-
engage with these people. When you send this e-mail, you want to apply a filter with the
inactive tag, so only those subscribers will get it.
63 / 327
Martin Joo - Domain-Driven Design with Laravel
Broadcasts
Broadcasts are e-mails you want to send one time to a given set of subscribers. First, you
write the content and save it; after that, you either send it manually or schedule it for a later
time.
Either way, you define which subscribers will get the mail. You can filter them by:
We won't implement products and purchases in the sample application, so our filters will
work based on tags and forms.
64 / 327
Martin Joo - Domain-Driven Design with Laravel
Sequences
Sequences are one of the most powerful features of ConvertKit. Imagine you want to create
an e-mail course on Domain-Driven Design. You write eight e-mails with eight different
concepts and ideas. If you use broadcasts to do this, you get the following results:
On the 1st of June, you have 1000 subscribers, and you send out the first mail.
From the 1st through the 8th of July, you get 100 new subscribers. They haven't
received the first mail because they joined your newsletter a few days later.
On the 8th of July, you have 1100 subscribers, and you send out the second mail.
The 100 new subscribers never got the first e-mail. They started your course on the 2nd e-
mail. This is obviously not what we want. The first part of the solution is to create a
sequence. The second part will be discussed in the next chapter.
A sequence is a set of e-mails grouped together. Using the DDD e-mail course as an
example, you can create a sequence like this:
We want to send one e-mail per week but only on Wednesday. So we can specify delays
between each e-mail:
1st e-mail will be sent on the first Wednesday after you publish the sequence.
2nd e-mail will be sent on the 2nd Wednesday.
3rd e-mail will be sent on the 3rd Wednesday.
We can implement this logic using a scheduled console command. ConvertKit even lets you
create automation where you can link together multiple sequences using conditionals.
Each e-mail in the sequence can have custom subscriber filters; each subscriber has a
different status when consuming a sequence. Let's take a look at an example.
65 / 327
Martin Joo - Domain-Driven Design with Laravel
Subscriber 1 Tag 1
Subscriber 2 Tag 2
Subscriber 4 -
#1 E-mail Tag 1
#2 E-mail Tag 2
#3 E-mail -
Each e-mail will be sent a week after the previous one. The results will be:
66 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see, each subscriber will get different e-mails, and it takes a different time to
complete the sequence.
Automations
Let's say you have put together an excellent sequence from Laravel tips and other helpful
content. It contains ten e-mails, and you send one e-mail every week. At the moment, you
have 1000 subscribers, and you start your sequence. People love it, and you gain some
more subscribers. Now, you have 1500 people on your list. But what happens with the new
500 subscribers? They all missed your content because they were not your subscribers
when you started your sequence. Wouldn't it be great if we could come up with some
solution?
ConvertKit solves this problem by using automation. Automation is a "flow" of events and
actions, something like this:
"When a person subscribes to my e-mail list, add them to my awesome e-mail sequence."
This automation makes it possible that every new subscriber will get your important and
valuable e-mails. As you can see from the examples, there are two essential parts of
automation:
In the demo app, we will handle the following events and actions:
We'll build them in an easy-to-extend way, so adding new events or actions will be
straightforward. It's an exciting feature with a lot of fun stuff!
67 / 327
Martin Joo - Domain-Driven Design with Laravel
Other Features
When you write an e-mail in ConvertKit, it will embed a 1px image in the content. When the
image is loaded, ConvertKit knows that a particular subscriber opened the e-mail. When the
e-mail has a link, and you click on it, it won't redirect you directly to the destination. First, it
will open a ConvertKit URL and redirect you to the original destination. We won't implement
these features, but we can simulate them with an API to track these metrics. From these
metrics, we can craft some interesting reports. When it comes to sequences, there are two
types of reports:
For a particular e-mail in the sequence. In the example above, #3 E-mail was sent out
to 4 subscribers; meanwhile, #1 E-mail was sent out to only two subscribers.
For the whole sequence. For example, eight e-mails were sent all together in the
example above:
Filters and Forms. We will have a Form model, but we won't implement a form builder.
Filters can be created using forms and tags.
68 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see from these chapters, building e-mail marketing software can be pretty
complicated! This is why I chose this topic:
The domain model is perfect for introducing DDD. From these features, we can already
identify some important domains: subscriber, broadcast, sequence, and report.
Probably all of these can be a separate domain.
The business logic is complicated. Just look at the figure above. Or think about sending
10000 e-mails or importing 50000 subscribers. We will face some challenges.
We need to avoid code duplication. A broadcast and a sequence e-mail sound very
similar to each other. We probably need some abstraction.
Reports everywhere! Writing reports-related code can be pretty fun but often become a
big mess.
I will use Inertiajs because it's a handy tool. If you're not familiar with it, don't panic.
From the BE perspective, it's only one line of code in the controller.
But we also need some API endpoints to track the open and click rates and create
subscribers. These requests come outside of our application (from an embedded form
or a script in the e-mail's content).
For these reasons, I think ConvertKit is the perfect example to replicate and learn Domain-
Driven Design!
69 / 327
Martin Joo - Domain-Driven Design with Laravel
User Stories
Now we are ready to write some user stories that we will implement later. However, I don't
want to overload you with every tiny detail, so we're focusing only on the essential stories,
the application's core.
Subscribers
Filtering subscribers It's not really a user story but we need it in multiple places.
Broadcasts
Sending a First it filters out the subscribers and then it queues up the e-
broadcast mails.
Sequences
Adding a sequence With filters and schedule (such as 3 days after the last mail).
mail
Proceeding a This will handle all the scheduling logic. It needs to be scheduled
sequence to run frequently. It will send the e-mails.
70 / 327
Martin Joo - Domain-Driven Design with Laravel
Tracking
Automations
Creating Each automation has one event and any number of actions.
automations
Running When the specified event occurs we need to run the actions.
automations
Reports
New subscribers How many new subscribers are this week, this month, today, or
in total
Performance for a Either for a broadcast or a sequence mail (open rate, click rate).
sent mail
71 / 327
Martin Joo - Domain-Driven Design with Laravel
Data Modeling
This is one of the most crucial steps in every project, so let's nail it!
Subscribers
The easiest part is the subscriber-related tables, so let's start there. We know that
subscribers have tags; this is a many-to-many relationship, so we need a pivot table.
Subscribers also have a form where they come from. This is a one-to-many relationship.
72 / 327
Martin Joo - Domain-Driven Design with Laravel
Broadcasts
It's not a bad solution, but I see some problems that may occur:
We need two extra tables and possibly six eloquent relationships to implement filtering
by tags and forms.
I don't like to predict the future, but filters are almost always something that will change
over time. And being a ConvertKit user myself, I can say that a production e-mail
marketing system also has filters like:
73 / 327
Martin Joo - Domain-Driven Design with Laravel
We need new tables and relationships for every new use case if we choose this
structure.
So it can work, but I don't feel it's flexible enough to handle real-world use-cases.
Instead of using separate tables, we can add a JSON column to the broadcasts table,
something like this:
{
"form_ids": [1,2,3],
"tag_ids": [12,4]
}
We can store a JSON array like this in a column. The data structure above describes a filter:
Easy to implement.
Easy to extend.
Fast implementation. It can be an advantage in many projects and a disadvantage in
others.
Later, it's easy to convert it to separate tables if you change your mind.
No data integrity. What if you delete the tag with the ID of 4? You need extra code to
guarantee that none of the filters reference it anymore. However, in this situation, it's
only partly true. Using broadcast_form and broadcast_tag pivot tables gives you
data integrity. But we know that we need the same filters for sequences later. It means
we are forced to use polymorph relationships, and instead of broadcast_id we have
74 / 327
Martin Joo - Domain-Driven Design with Laravel
Isn't a random JSON structure the same as having random associative arrays? Yes, but we
can quickly solve this by casting this value to a DTO or value object.
Important note: I will use a JSON column. Partly because this is educational content, and
by using JSON, I have an opportunity to show you how to deal with unstructured data and
make it a joy to work with it. I think you can benefit from it if you're working with legacy
systems.
75 / 327
Martin Joo - Domain-Driven Design with Laravel
SentMails
Moving on, what happens after a broadcast has been sent? We know from the user stories
that we need to calculate reports like:
So we need something that represents a "sent e-mail." After sending a broadcast, it will
send X e-mails and need to create X rows in a table, where X is the number of subscribers.
It's also a requirement to track sequence e-mails, so we need to do the same with
sequence e-mails. We need a table with a polymorph relationship. What does this table
represent? As I said earlier, it stores e-mails that already have been sent, so my suggestion
for the name is sent_mails :
13:00:00 13:24:00
09:13:00
Since the table name is sent_mails , I've decided to use the sendable as the name for
the morph relationship. The sendable_type will contain the fully-qualified class name of
the models that I don't know precisely at the moment, so I just wrote the class names.
The tracking API will update the opened_at and clicked_at columns for a given
subscriber and sendable. From this table, we can calculate the reports. For example, the
performance of the broadcast:
76 / 327
Martin Joo - Domain-Driven Design with Laravel
From this table, we can also query statistics for an individual subscriber, such as:
Once again: I don't want to predict the future, but reports are usually very "dynamic" by
nature. This means that the management always wants more exotic reports. For me, this
structure looks like a good starting point that supports several different statistics.
77 / 327
Martin Joo - Domain-Driven Design with Laravel
Now let's play a little with numbers. First, let me make some assumptions:
100 000 users * 5 000 e-mails * 52 weeks = 26 000 000 000 rows in sent_mails in one
year. It seems incredibly high, right? Let's make some other assumptions:
Acquiring 100 000 users will probably take YEARS. It depends on many factors, of
course, but it's a very high number. ConvertKit has about 500 000 users (the number
comes from their site), and it's an ~8-year-old business in a niche where the CEO is
very well-known.
5 000 is an entirely made-up number; I couldn't find a reliable number online.
At this stage, I wouldn't worry about this kind of problem. Instead, I want to be ready for the
first 100 or 1000 users. What can we do to make the DB effective?
The indexing itself can speed up your application. The next step is to write queries that can
use these indexes, so 'never' write something like this:
select count(*)
from sent_mails
where year(sent_at) = "2022";
This query won't use your index on the sent_at column. Simply because MySQL cannot
use indexes if you're using the year function. So instead of using a function, write queries
like that:
78 / 327
Martin Joo - Domain-Driven Design with Laravel
select count(*)
from sent_mails
where sent_at between "2022-01-01 00:00:00" and "2022-12-31
23:59:59";
So you've created indexes and written good queries, but your reports are still slow. What
else can you do?
You can always look for N+1 query problems. It's a very common problem when you're
working with ORM.
You can optimize select * queries. Most of the time, it's unnecessary to query every
column.
Your next step can be database partitioning. I'm talking about horizontal partitioning. It's a
way to tell MySQL how to store your data physically. You can create multiple portions
(partitions) from a table, and the engine will store these portions in different files. For
example, you can define rules such as:
This way, you can write queries that only touch a small number of partitions (fewer rows to
fetch).
If you're still having problems, you can always use Redis. These historical reports are static
by nature. If you want, you can replicate the whole sent_mails in Redis and serve the
requests from there. There are a dozen different ways to do that.
79 / 327
Martin Joo - Domain-Driven Design with Laravel
Sequences
We need to handle two different time units when scheduling sequence e-mails:
Hours
Days
They also want to specify which days the given e-mail can be sent:
Before modeling the tables, let's discuss how to store these schedules:
delay stores the number of days/hours we need to wait after the previous e-mail.
unit is either days or hours. This column can be omitted if we store every delay in
hours. In this case, five becomes 120. This can work, but we don't have to convert
hours from days in the code if this table has a unit column. It's easier, in my opinion.
80 / 327
Martin Joo - Domain-Driven Design with Laravel
allowed_days is an object that contains every day and a bool value associated with
them. This tells us if the e-mail should be sent on a given day. In the first example, the
e-mail will only be sent on Wednesday. In the second example, it'll be sent on any
weekday.
With this simple table, we can describe any scheduling logic we have right now.
Why did we create a new table for schedules but use a JSON column for filters before? This
schedule can be also be stored as JSON, right? Yes, that's true. I have two reasons to go
with a separate table:
Believe it or not, scheduling logic is more straightforward and more "static" than
filtering. This simple use case is the whole scheduling logic of ConvertKit! And I don't
see too much change in that requirement.
Since we used a JSON column earlier, I think it's good to go with a separate table this
time. Now you can see the problems solved with DTOs and value objects. After
implementing these features, you can decide which is better for your current/future
project.
81 / 327
Martin Joo - Domain-Driven Design with Laravel
It's quite easy. A sequence mail is very similar to a broadcast; it also has:
Subject line
HTML content
JSON filters
A sequence is just a "container" with a name and status. But it's important because only
published sequences can send e-mails to subscribers. And as we've discussed earlier,
each sequence mail becomes a row in the sent_mails table after it was sent.
82 / 327
Martin Joo - Domain-Driven Design with Laravel
Automations
The last important feature I want to model before moving on is automation. As I said earlier,
we need to build flows like this one:
"When a person subscribes to my e-mail list, add them to my awesome e-mail sequence."
At the database level, it's straightforward; I think we only need two tables to represent such
automation:
id name
1 Add to newsletter
The first row represents the event, so the "When a person subscribes to my e-mail list" part.
While the second row is the action itself, or the "add them to my awesome e-mail
sequence" part. The type and name columns are self-explaining. The value contains the ID
of the "thing" that needs to be dealt with. Once again, I'm using a JSON column. However,
these two examples can be done using a simple integer; what happens when there are
more advanced, complicated steps? Such as "When a subscriber purchases a particular
product, then wait for two days and send an upsell e-mil." In this case, we can benefit from
a JSON column. On the other hand, we sacrifice nothing in the case of simple examples.
As you can see, it's a pretty simple database structure. However, we'll face some
challenges in the code.
83 / 327
Martin Joo - Domain-Driven Design with Laravel
Before we move on, let me answer a question: if every automation has only one event, why
not store it in the automation itself? Because this way, we have the flexibility to add multiple
events to automation if needed later.
84 / 327
Martin Joo - Domain-Driven Design with Laravel
Domains
The last step is to plan the domains of the application. Maybe this is a new thing for you,
but this is the easiest step. First, let's list all the tables we have come up with:
subscribers
tags
subscriber_tag
forms
broadcasts
sequences
sequence_mails
sequence_mail_schedules
sent_mail
automations
automation_steps
Table Group
subscribers Subscriber
tags Subscriber
subscriber_tag Subscriber
forms Subscriber
These groups will be the domains of the application. Because subscribers have tags and
forms, I've put tags and forms into the Subscriber group. If we had full form-builder
functionality with dozens of other classes, Form would have been a separate group. But
right now, they can be put into the Subscriber group.
85 / 327
Martin Joo - Domain-Driven Design with Laravel
Table Group
broadcasts Broadcast
sequences Sequence
sequence_mails Sequence
sequence_mail_schedules Sequence
This is one way of doing it. We create a group for Broadcast and another one for Sequence.
This approach has several benefits:
Maybe some domains will be too small? Such as the Broadcast. Right now, we only
have one model in it.
What about sent_mails ? Both sequences and broadcasts are using it, so we cannot
put it in one group. We can solve this using a Shared domain. It's kind of like a "joker"
domain where you can put classes used by multiple domains. It's not the best solution,
but it definitely works. And believe me, every project has a Shared or Common domain.
Literally, every larger application has one of these.
What about other shared classes between broadcasts and sequences? We are in the
design phase, so we only know about one shared class. But I'm 100% absolutely sure
that we need other classes. These all will be placed inside the Shared domain and will
be used by sequences and broadcasts. And remember, right now, we only have tables
and models. What about DTOs, value objects, actions, and so on? For example, both
broadcasts and sequences have filters that require a DTO or a value object (or maybe
both?). This will also live in the Shared domain.
I think this solution is not that bad, but broadcasts and sequences can be grouped in
another way:
86 / 327
Martin Joo - Domain-Driven Design with Laravel
Table Group
broadcasts Mail
sequences Mail
sequence_mails Mail
sequence_mail_schedules Mail
sent_mail Mail
We can invent a new domain called Mail. What are the disadvantages?
This domain can grow too large. With the current feature set, I'm sure it's going to be
okay, but I also feel like sequences will be more complicated in the future and require
more and more tables, classes, actions, and so on. So this is a valid risk.
We mix up two concepts. It can be wrong but also good. For example, not having a
Shared domain because of sent_mails is a good thing, in my opinion. So in this
particular case, it's not a disadvantage.
Other than that, having a Mail domain is the superior option, in my opinion. So our domains
look like this:
Subscriber
Mail
Automation
Shared
I listed Shared because I'm sure we're going to need it. Just a quick example: what if we
need a base model for every other model? Or a percent value object? As I said earlier, I
think it's okay to cheat a little bit with a Shared domain.
subscribers:
attributes:
- email
87 / 327
Martin Joo - Domain-Driven Design with Laravel
- first_name
- last_name
relationships:
- form: A subscriber has one form
- received_mails: A subscirber has many received mails.
They can be broadcasts and sequence mails
- tags: A subscriber has many tags
tags:
attributes:
- title
relationships:
- subscribers: A tag has many subscribers
forms:
attributes:
- title
- content: The HTML that can be embedded into websites
relationships:
- subscribers: A form has many subscribers
broadcasts:
attributes:
- title
- content: HTML content
- filters: JSON field
- status
- sent_at
relationships:
88 / 327
Martin Joo - Domain-Driven Design with Laravel
sequences:
attributes:
- title
- status: draft, published
relationships:
- sequence_mails: A sequence has many sequence mails
sequence_mails:
attributes:
- subject
- status: draft, published
- content: HTML content
- filters: JSON field
relationships:
- sequence: A sequence mail belongs to one sequence
- schedule: A sequence mail belongs to one schedule
- sent_mails: A sequence mail has many sent mails. One for
every subscriber.
sequence_mail_schedules:
description: Each sequence mail has a unique schedule such
as 2 days after the last e-mail, only on Fridays
attributes:
- delay: For example, 2 days after
- unit: hour, day
- allowed_days: On which days the mail can be sent
89 / 327
Martin Joo - Domain-Driven Design with Laravel
sent_mails:
description: After sending a broadcast or a sequence mail it
becomes a sent_mail record.
attributes:
- sendable_id: The ID of the broadcast or the sequence
mail
- sendable_type: broadcast, sequence_mail
- subscriber_id
- sent_at
- opened_at
- clicked_at
automations:
description: It is like a folder for the actual steps
attributes:
- name
relationships:
- steps: Automation has many steps
automation_steps:
description: It contains a flow of an event and multiple
actions for an automation
attributes:
- automation_id
- type: event or action
- name
- value
relationships:
- automation: Automation step belongs to one automation
90 / 327
Martin Joo - Domain-Driven Design with Laravel
"autoload": {
"psr-4": {
"App\\": "app/",
"Domain\\": "src/Domain/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
The App namespace remains the same, and we add a new Domain namespace that
references the src/Domain folder. This src/Domain folder contains all of the domains:
91 / 327
Martin Joo - Domain-Driven Design with Laravel
You absolutely don't need to use the Domain folder as the root; it's just an older habit of
mine. Why the src directory? Because many tools will search for either the app or the src
directory. I'm talking about tools like static analysis, some test helpers, and other developer
tools. So by using the src, we can use most of these tools without any further configuration.
Each of those domain folders (such as the Subscriber or the Automation) will have a
structure like this:
92 / 327
Martin Joo - Domain-Driven Design with Laravel
This is your regular app structure. Each class type has a separate folder. Nothing
unexpected so far.
If we use domains to structure our code, where to put the User model? Right into the
Shared domain. Every single domain uses it, so it's shared in the application. To do that, we
have to change Laravel's User provider configuration in the config/auth.php:
'providers' !( [
'users' !( [
'driver' !( 'eloquent',
'model' !( \Domain\Shared\Models\User!$class,
],
],
The model was changed from the default App\Models\User::class . After that, we can
move the user into the Shared\Models namespace.
In most applications, I use a BaseModel that extends Eloquent's model class and is
extended by all models. It's also a class that can be placed inside the Shared domain.
When using domains, you have to override a method on the model called newFactory .
This method will return a new factory instance associated with the model. By default,
Laravel tries to resolve the factory based on the model's namespace. But in our case, it
tries to instantiate a class like
Database\Factories\Domain\Subscriber\Models\SubscriberFactory . It's clearly not
what we want.
93 / 327
Martin Joo - Domain-Driven Design with Laravel
Why not put the factories inside the domain folder? Because they are not related to
business logic:
Factories are only used in tests and seeders, so the subscriber domain can do its job
without the SubscriberFactory .
I don't want yet another folder inside the Domain\Subscriber .
I think they should be left in the default database/factories directory for these reasons.
94 / 327
Martin Joo - Domain-Driven Design with Laravel
Back to the BaseModel , we need to tell Laravel how it should resolve these factories:
namespace Domain\Shared\Models;
return app(
"Database\\Factories\\{$domain}\\{$model}Factory"
);
}
}
$parts = [
'Domain',
'Subscriber',
'Models',
'Subscriber',
];
95 / 327
Martin Joo - Domain-Driven Design with Laravel
Applications
As I mentioned in the first section, I will use Inertiajs in the demo application. Please don't
panic if you don't know it. The main difference between an Inertiajs and a classic Blade
MVC application is one line on the Laravel side. Instead of returning a view , you have to
return an Inertia specific class from the controller. On the frontend side, it's a Vuejs
application, so later in this book, I'll share code snippets from good, old Vuejs components
(but, of course, 95.13% of code snippets will show you PHP and Laravel code).
So our main app will be an Inertiajs app, but we also need some APIs (for example, tracking
when a user opens an e-mail or clicks on a link). And, of course, we'll have some console
commands (like every application has some). Now let's see what the app directory looks
like:
As you can see, it follows the default structure of a new Laravel app. In my opinion, it's
important to stay close to the default stack so that Laravel upgrades will be as easy as
possible.
If you take a closer look inside the Http directory, you can see there's an Api and a Web
folder:
96 / 327
Martin Joo - Domain-Driven Design with Laravel
The Web folder contains the application-related backend logic for the Inertiajs app, while
the Api folder contains the API-specific controllers. As you can see, each of those folders is
namespaced by domains. Since both of these applications use middlewares, I left them in
the Http folder. I find this setup very easy to understand and a joy to maintain. There's no
unnecessary complexity.
97 / 327
Martin Joo - Domain-Driven Design with Laravel
Shared domain
Now you probably have a better idea about the Shared domain. It contains classes that are
shared across many (often every) domains. So far, we have put the User and the
BaseModel classes inside the Shared domain. What other classes will it contain?
Any base class like the BaseModel . For example, view models will have a parent class
that lives inside the Shared domain (more on that later).
General-purpose classes, such as a Percentage value object, a DateFilter , an
Eloquent trait.
Or even some actions can be placed inside this domain. For example, you need to read
a CSV file and write an action for this. What if you need to use this class from many
domains?
Users
I want to clarify one more thing before moving on. There are no teams, companies, or
tenants in this application. Users register and can create their subscribers, broadcasts,
automations, etc. So (almost) every table has a user_id column.
98 / 327
Martin Joo - Domain-Driven Design with Laravel
Subscribers
Creating A New Subscriber
Now that domains and applications have been set up let's write some code! I will start with
subscribers because this is the most simple domain.
A subscriber can have only one form but many tags. Now let's take a look at the subscriber
model:
99 / 327
Martin Joo - Domain-Driven Design with Laravel
At this point, there's nothing too interesting. A many-to-many tag and a one-to-many form
relationship. In the form relationship, I use the withDefault method. By default, if a
subscriber does not have a form (it's a nullable column), Laravel will return null , and it can
cause some bugs and unnecessary if statements. Using the withDefault Laravel will
return an empty Form object instead of null . It can be helpful in some situations.
Now, let's see the subscriber DTO. In code, I don't use the word 'DTO'. I call the
namespace DataTransferObjects , and I suffix the classes with the term Data .
namespace Domain\Subscriber\DataTransferObjects;
use Spatie\LaravelData\Data;
100 / 327
Martin Joo - Domain-Driven Design with Laravel
The ID is always optional because there's no ID when a POST request comes in, and
we transform it into a DTO. But when a PUT request comes in, there's an ID. And
remember, this DTO will also be used when querying the subscribers and returning a
response.
Properties are in $snake_case format. By default, laravel-data will map the request or
the model attributes with the DTO's properties. By using snake_case variables, there's
no extra work to do. This also applies to plain DTO classes (when you're not using the
laravel-data package). If you want your properties to be in camelCase you have to
write the logic that transforms the model_attributes to dtoAttributes . I have done
it in the past, but after a while, it gets messy. So nowadays, I'm using snake_case
everywhere:
Models
DTOs
Request parameters
$form is a nested property. As you can see, there's a FormData class that is a nested
property of the SubscriberData . Just as the Subscriber model has a Form
attribute. laravel-data helps us make this nesting very easy. We'll talk about it in more
detail later.
$tags is also a nested property, but a subscriber has many tags, and as you can see
in DTOs, we can use the DataCollection class to do this mapping. It comes from the
laravel-data package.
101 / 327
Martin Joo - Domain-Driven Design with Laravel
The $form and $tags are nullable properties because not every subscriber has them.
I know it's probably a lot of new information but bare with me. Now let's see what the
request and the validation rules look like:
There are some basic validation rules here, maybe the e-mail that is a bit more complicated,
so let me explain it:
The email has to be unique. Laravel will run a select query to check if there's a
record in the subscribers table with the e-mail in the request.
It works perfectly when creating a new subscriber.
When it's an update, we need to ignore the currently updated subscriber's e-mail
address. Otherwise, Laravel will throw an exception because the subscribers table
already contains this e-mail address.
102 / 327
Martin Joo - Domain-Driven Design with Laravel
By the way, can you spot the two differences compared to a standard Laravel request
rules function?
What the hack is going on? Here's the thing: this method is in the SubscriberData class.
As I said earlier, when using the laravel-data package, we have one class for:
Request
Resource
DTO
And the package expects a static rules function. Since it's static, in the ignore , I cannot
write something like $this->id as I would in the case of a Request class. So I get the
subscriber ID from the request parameter. In the case of an update request, the URL looks
like this: subscribers/{subscriber} , so the request('subscriber') will return the
Subscriber model that is being updated.
return Redirect!$route('subscribers.index');
}
103 / 327
Martin Joo - Domain-Driven Design with Laravel
I will talk about the action later, but now let's focus on the DTO. As you can see, we can
inject the SubscriberData class into the method, and the package will automatically
create a new instance from the request. But I also inject the Request itself to get the
currently logged-in user.
I could put a user property in the SubscriberData but remember we will also use this
class to return as a response, and I don't want to include sensitive user data in the
responses if it's not necessary. And in most cases, the user is not needed on the frontend.
Remember, when users log in to the application, they will only see their information. So
there's no need to display to creator user's name or anything like that. For that reason, I left
out the user property from the DTO.
By default, laravel-data will do a one-to-one mapping from the request to the data object.
For each property within the data object, a value with the same key will be searched within
the request values. This default behavior works in most cases. But if you take a look at the
data class, it has properties like tags and form ; meanwhile, in request, we expect keys
like tag_ids and form_id . So how can we override the default mapping?
104 / 327
Martin Joo - Domain-Driven Design with Laravel
In a moment, I'll show you the TagData and the FormData classes. A from function is
available in every data class; it's a simple factory function that creates a new DTO from an
array. We can use this and override the array keys we want to transform:
It's very similar to Laravel resource classes. Before we move on, let's summarize how the
whole flow works:
{
"id": null,
"first_name": "Test",
"last_name": "Subscriber",
"email": "[email protected]",
"form_id": 2,
"tag_ids": [1, 2]
}
105 / 327
Martin Joo - Domain-Driven Design with Laravel
106 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\DataTransferObjects;
use Spatie\LaravelData\Data;
It only has a title and content property (the HTML of the form itself). The TagData is even
more simple:
namespace Domain\Subscriber\DataTransferObjects;
use Spatie\LaravelData\Data;
107 / 327
Martin Joo - Domain-Driven Design with Laravel
It only has a title. As you can see, every DTO has an optional ID property. Now that you
have seen the basic data structure, let's move on to the action that creates a new
subscriber:
namespace Domain\Subscriber\Actions;
class UpsertSubscriberAction
{
public static function execute(
SubscriberData $data,
User $user
): Subscriber {
$subscriber = Subscriber!$updateOrCreate(
[
'id' !( $data!%id,
],
[
!!*$data!%all(),
'form_id' !( $data!%form?!%id,
'user_id' !( $user!%id,
],
);
$subscriber!%tags()!%sync(
$data!%tags!%toCollection()!%pluck('id')
);
108 / 327
Martin Joo - Domain-Driven Design with Laravel
I use the name upsert for almost all of my actions. It can be used to create or update
a subscriber.
The first one is used in a select query. If a record is found with these values, it
will run an update query. If it's not found, it will run an insert query.
The second one contains the attributes that will be inserted or updated.
Every DTO has an all method that returns the properties as an array. This is a handy
method and can be used with Eloquent, as in this example. But when you have nested
properties, the array looks like this:
[
'first_name' !( 'John',
'last_name' !( 'Doe',
'form' !( [
'id' !( 1,
'title' !( 'Some Form',
'content' !( '!!*',
],
]
Obviously, we can't save the whole form array; instead, we only want to store the id . This
is why the second parameter of updateOrCreate is constructed like this:
109 / 327
Martin Joo - Domain-Driven Design with Laravel
[
!!*$data!%all(),
'form_id' !( $data!%form?!%id,
'user_id' !( $user!%id,
];
Since PHP 8, the ... operator can be used with associative arrays, so the example above
is equivalent to this:
[
array_merge(
$data!%all(),
[
'form_id' !( $data!%form?!%id,
'user_id' !( $user!%id,
],
);
];
Using ... , we have a much cleaner solution. I will use this technique a lot. There are three
other important things in this action:
The id of the SubscriberData (and any other DTO) is nullable. So when it contains a
new subscriber, the updateOrCreate will get null as the ID, and it will run an
insert query.
The $form is a nullable property in the SubscriberData class, so I use the ?
operator. If the form is null, it won't throw an exception but instead, use a null value.
It's also a PHP8 feature.
110 / 327
Martin Joo - Domain-Driven Design with Laravel
$subscriber!%tags()!%sync(
$data!%tags!%toCollection()!%pluck('id')
);
Updating A Subscriber
Now let's see how the update method looks in the SubscriberController :
return Redirect!$route('subscribers.index');
}
As you can see, it's the same as the store . That's because the action takes care of both
actions. Why doesn't it have a Subscriber $subscriber argument from a route binding?
111 / 327
Martin Joo - Domain-Driven Design with Laravel
112 / 327
Martin Joo - Domain-Driven Design with Laravel
View Models
To better understand creating and updating a subscriber, we need to take a closer look at
view models. As I said in the introduction, these classes contain data that the frontend
needs. The only place they used in the backend is the controller:
namespace App\Http\Web\Controllers\Subscriber;
use Inertia\Response;
class SubscriberController
{
public function create(): Response
{
return Inertia!$render('Subscriber/Form', [
'model' !( new UpsertSubscriberViewModel(),
]);
}
If you remember from the good old Blade days, create and edit renders the create and
edit page for a subscriber. But instead of rendering a Blade view, they are returning an
Inertia\Response instance. This is the only difference when using inertia. The
Subscriber/Form is the path of a Vue component that contains the subscriber form. The
113 / 327
Martin Joo - Domain-Driven Design with Laravel
second argument is the data we want to pass to this component as its props. In both
cases, I give an instance of the UpsertSubscriberViewModel . Here's what this class looks
like:
namespace Domain\Subscriber\ViewModels;
use Domain\Shared\ViewModels\ViewModel;
return SubscriberData!$from(
$this!%subscriber!%load('tags', 'form')
);
}
114 / 327
Martin Joo - Domain-Driven Design with Laravel
/**
* @return Collection<TagData>
!"
public function tags(): Collection
{
return Tag!$all()
!%map(fn (Tag $tag) !( TagData!$from($tag));
}
/**
* @return Collection<FormData>
!"
public function forms(): Collection
{
return Form!$all()
!%map(fn (Form $form) !( FormData!$from($form));
}
}
The view model queries every tag and form from the database because we need to display
them in a dropdown so users can choose from them.
Since this class handles both the create and the edit page, the subscriber can be
null . When creating a new one, it will be null, so the input fields on the form will all be
empty. When updating an existing one, it will have a value, so the input values on the
form will be populated.
Every method returns a data class. Now you have a better understanding of DTOs:
115 / 327
Martin Joo - Domain-Driven Design with Laravel
Every method we want to access on the frontend as a data property (more on that in a
minute) must be public.
There is no get prefix in the method names. Two reasons for this:
When returning a view model from a controller, we want an array such as:
116 / 327
Martin Joo - Domain-Driven Design with Laravel
[
'subscriber' !( [
'first_name' !( 'John',
'last_name' !( 'Doe',
'form' !( [
'id' !( 1,
'title' !( 'Join the Newsletter',
'content' !( '!!*',
],
'tags' !( [
[
'id' !( 1,
'title' !( 'Laravel',
],
[
'id' !( 2,
'title' !( 'Vue',
],
],
],
'forms' !( [
!) Every form from the database for the dropdown
],
'tags' !( [
!) Every tag from the database for the dropdown
],
];
In a moment, I will show you how we can do this. For now, imagine we have this array from
the view model. When we use this in the controller by this line:
117 / 327
Martin Joo - Domain-Driven Design with Laravel
return Inertia!$render('Subscriber/Form', [
'model' !( new UpsertSubscriberViewModel(),
]);
Inertia will pass this array to the Vue component, where we can accept it as a prop:
export default {
props: {
model: {
type: Object,
required: true,
},
},
}
118 / 327
Martin Joo - Domain-Driven Design with Laravel
This screenshot was taken on an edit form (the ID is not null). Of course, you don't have to
use the root level model key, but I find it very useful.
119 / 327
Martin Joo - Domain-Driven Design with Laravel
From this flow, you can also see that Inertia is not a framework. It's a library that glues
Laravel and Vue together.
Now, let's see the magic that creates an array from methods:
namespace Domain\Shared\ViewModels;
120 / 327
Martin Joo - Domain-Driven Design with Laravel
}
}
This method is defined in the parent ViewModel class that every view model extends. We
only need to implement the Arrayable interface, and Laravel will take care of the rest. I
don't want to go into too many details about how the reflection API works, but the main
logic is this:
We want every method from the view model except __construct and toArray .
We also want to reject every private or protected method. Only public methods
represent data properties.
We want the array keys to be in snake_case . So a method called automationSteps
will become automation_steps in the array and the Vue component.
This is how the view model gets converted into an array and passed to the Vue component
as a property.
121 / 327
Martin Joo - Domain-Driven Design with Laravel
Vue Component
This book is about backend, but I'll also show you some Vue examples. So let's see how
the component handles the view model.
export default {
props: {
model: {
type: Object,
required: true,
},
},
}
data() {
return {
form: {
id: null,
first_name: null,
last_name: null,
email: null,
form_id: null,
tag_ids: [],
}
};
}
122 / 327
Martin Joo - Domain-Driven Design with Laravel
I set everything to null, and in the created hook, I initialize these properties:
created() {
if (!this.model.subscriber) {
return;
}
this.form = {
id: this.model.subscriber.id,
first_name: this.model.subscriber.first_name,
last_name: this.model.subscriber.last_name,
email: this.model.subscriber.email,
form_id: this.model.subscriber.form.id,
tag_ids: this.model.subscriber.tags.map(t !( t.id),
};
}
I do this in the created method because the model.subscriber can be null, so,
unfortunately, it won't work in the data .
<div>
<label for="first_name">
First Name
!,label>
<input v-model="form.first_name" id="first_name" type="text"
placeholder="Jane">
!,div>
123 / 327
Martin Joo - Domain-Driven Design with Laravel
methods: {
submit() {
if (this.model.subscriber) {
this.$inertia.put(
`/subscribers/${this.model.subscriber.id}`,
this.form
)
} else {
this.$inertia.post('/subscribers', this.form)
}
},
}
If there's a subscriber, it will call the PUT endpoint (the update method in the controller);
otherwise, it calls the POST endpoint (the store method in the controller). The
this.$inertia.post is very similar to await axios.post but it will do a page reload
after getting the response. This is why it's synchronous. And it's perfect for the use case
because after submitting the subscriber, I want to redirect the user back to the index page.
You can see this in the controller:
124 / 327
Martin Joo - Domain-Driven Design with Laravel
use Illuminate\Http\RedirectResponse;
return Redirect!$route('subscribers.index');
}
I highly recommend you to go through the last few chapters one more time because it'll be
a recurring theme of this book, so it's essential to understand how DTOs and view models
work.
125 / 327
Martin Joo - Domain-Driven Design with Laravel
Get Subscribers
There's no DTO involved here, only a view model. A typical e-mail list can contain
subscribers, ranging from a few thousand to tens of thousands of people, so pagination is
crucial. This is why you see the $request->get('page', 1) is passed to the view model.
There's bad news about pagination: since we want to return DTOs from the view model, we
cannot just write something like:
return Subscriber!$paginate();
Because this will return a pagination object, and we cannot manipulate the models inside
it. But luckily, it's very easy to use Laravel's pagination features without using the
paginate method. First of all, the frontend won't list available pages. It only shows:
This is called simple pagination in Laravel. It's easy to achieve this behavior:
126 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\ViewModels;
$items = $items!%slice(
self!$PER_PAGE * ($this!%currentPage - 1)
);
127 / 327
Martin Joo - Domain-Driven Design with Laravel
],
);
}
Unfortunately, when creating paginators manually, the Laravel documentation says here, we
need to query every item and slice the results before passing them to the Paginator class.
You can easily fix this behavior by:
However, I don't want to recreate existing logic, and usually, a user has a few thousand or
ten thousand subscribers, so we're not querying millions of records. This is highly
subjective, but I would only optimize this when some performance issues occur.
128 / 327
Martin Joo - Domain-Driven Design with Laravel
<div>
<div>
Total: {{ this.total }}
!,div>
<div>
<button @click="$emit('paginatedPrev')"
:disabled="this.current_page !!' 1">
Prev
!,button>
<button @click="$emit('paginatedNext')">
Next
!,button>
!,div>
!,div>
<Pagination
:total="model.total"
:current_page="model.subscribers.current_page"
@paginated-prev="prevPage()"
@paginated-next="nextPage()"
></Pagination>
methods: {
nextPage() {
if (!this.model.subscribers.next_page_url) {
129 / 327
Martin Joo - Domain-Driven Design with Laravel
return;
}
this.$inertia.get(this.model.subscribers.next_page_url);
},
prevPage() {
if (!this.model.subscribers.prev_page_url) {
return;
}
this.$inertia.get(this.model.subscribers.prev_page_url);
},
}
Subscriber!$with(['form', 'tags'])
!%orderBy('first_name')
!%get()
!%map(fn (Subscriber $subscriber) !(
SubscriberData!$from($subscriber));
You can see there's no user id in this query. That's because I'm using a global scope that
adds a where condition to every query:
130 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Shared\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
It retrieves the currently logged-in user from the request and adds a where condition to the
query. It's being used in a trait called HasUser :
namespace Domain\Shared\Models\Concerns;
use Domain\Shared\Models\Scopes\UserScope;
use Domain\Shared\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait HasUser
{
public function user(): BelongsTo
{
131 / 327
Martin Joo - Domain-Driven Design with Laravel
return $this!%belongsTo(User!$class);
}
Now anytime you write a subscriber query, a where user_id = ? will be added. Let's
discuss the advantages and disadvantages of this solution. First, the disadvantages:
If you don't use this scope, you have to write a where statement in EVERY QUERY for
the next X years. What happens if you forget it? Users will see other users' subscribers.
This is a clear violation of the GDPR and possibly some other laws. Please note that
there is an $877 million GDPR fine because of a poorly implemented cookie contest or
a $255 million one caused by a poor explanation of a data processing practice. $255
million for a wrong description! And, of course, newcomers will forget to filter by user,
132 / 327
Martin Joo - Domain-Driven Design with Laravel
and you need to catch these bugs while reviewing their code. Will you catch them all?
For me, the luxury of knowing that my code won't send me to jail is absolutely 100% worth
the price of having a little bit of magic in the project and explaining it to new developers.
Subscriber!$with(['form', 'tags'])
!%orderBy('first_name')
!%get()
!%map(fn (Subscriber $subscriber) !(
SubscriberData!$from($subscriber)
);
As you can see, the last line creates a SubscriberData from the Subscriber model by
using the from method. The from method will work with requests and models by default.
In both cases, it converts the object to an array (by calling the all or the toArray
method) and tries to populate the DTO's properties from this array. However, we can
override this default behavior by defining a fromModel method on the SubscriberData
class:
133 / 327
Martin Joo - Domain-Driven Design with Laravel
What is this Lazy thing? It's basically the same as the whenLoaded in HTTP resources.
For example, the $form property will only be evaluated if the form relationship is loaded
in the given $subscriber instance. Only then the callback of the whenLoaded will be
executed. This helps us to avoid N+1 queries. Otherwise, when querying the subscribers
and mapping each one to a data object, it would run two extra queries (loading the form
and the tags) for every subscriber. This is why the Lazy::whenLoaded is such a valuable
helper. To use this, we need to make one more change:
134 / 327
Martin Joo - Domain-Driven Design with Laravel
The $tags and the $form can be either a Lazy or the original type. Remember, when
we're creating a DTO from a request, we always have a FormData or DataCollection :
135 / 327
Martin Joo - Domain-Driven Design with Laravel
Here I'm not using the Lazy because this method is always called in a context where I only
have a single subscriber, such as:
Create
Update
Delete
In these cases, N+1 query is not a problem. For this reason, I use multi-type properties
Lazy|DataCollection or Lazy|FormData . If you're not using the laravel-data package
N+1 queries are still a problem that you need to solve.
One more challenge we need to solve when it comes to models and DTOs is attribute
accessors. First, let's define a full_name accessor in the Subscriber model:
It only needs a getter that constructs the full name from the first and last names. If you call
the toArray method on a model (just as we did in the fromModel ), you can see there's no
full_name attribute:
136 / 327
Martin Joo - Domain-Driven Design with Laravel
So when we're calling the toArray in the fromModel method, the full_name attribute
won't be included:
137 / 327
Martin Joo - Domain-Driven Design with Laravel
Now the DTO will have a full_name property. This issue is not strictly related to laravel-
data; you also need to solve this with your own DTOs (spoiler: the same as we did here).
138 / 327
Martin Joo - Domain-Driven Design with Laravel
The last DTO-related question is about Carbon attributes. I have a subscribed_at column
in the Subscriber model. You haven't seen this column in the UpsertSubscriberAction
because I use the useCurrent method in the migration:
$table!%unique(['user_id', 'email']);
});
}
This means the subscribed_at column will have a default NOW() value every time you
insert a record. Of course if you override it when calling the Subscriber::create() or the
Subscriber::updateOrCreate() the given value will be inserted.
The good news is Carbon cast is included by default; all we need to do is type-hint the
property:
139 / 327
Martin Joo - Domain-Driven Design with Laravel
Now, every time you pass a string, a Carbon, or a DateTime it will be converted to a Carbon
instance. You can also define the date format you want to use in the package's config
located in config/data.php .
It's important to note that the subscribed_at property is nullable because when creating a
DTO from a request, there's no subscribed_at in the request, so that it will be null. But
when creating it from a model, there will be a value. And once again, when you're calling the
$subscriber->toArray() , the subscribed_at will be a string. The Carbon type-hint in
the SubscriberData makes it possible to parse this string and cast it to a Carbon
instance.
This date and carbon casting is also a problem when using plain DTOs without any
package. By now, I hope you can see why a package like laravel-data is so useful to have.
Wrapping it up
I know this chapter was exhausting, but we've talked about the basics, and it's very
important to understand them. After all, this is what the SubscriberData looks like:
140 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\DataTransferObjects;
141 / 327
Martin Joo - Domain-Driven Design with Laravel
142 / 327
Martin Joo - Domain-Driven Design with Laravel
The real power of DTOs and actions comes from having multiple components or
applications that require the same logic to be reusable. In this application, we have two
features that can significantly benefit from actions and DTOs:
Subscriber import that can be used via the web app and from a console command
An API for creating a new subscriber
Subscriber API
Both of these features are required in an e-mail marketing application. Let's start with the
API. First of all, there's a common misunderstanding that you cannot have APIs when using
Inertia, and you cannot have multiple applications such as a web and a mobile app. That's
not true, and to be honest, I have no idea why people think that's the case.
use
App\Http\Api\Controllers\Subscriber\CreateSubscriberController;
Route!$middleware(['api', 'auth:sanctum'])!%group(function () {
Route!$post('subscribers',
CreateSubscriberController!$class);
});
143 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see from the namespace, the API lives under the App\Http\Api namespace,
which is our API application. The controller is straightforward:
namespace App\Http\Api\Controllers\Subscriber;
class CreateSubscriberController
{
public function !&invoke(
SubscriberData $data,
Request $request
): SubscriberData {
$subscriber = UpsertSubscriberAction!$execute(
$data, $request!%user()
);
return $subscriber!%getData();
}
}
This is very simple; it's almost identical to the SubscriberController::store method. It's
an invokable controller, which means it only has one method; there's no store, update,
create, or anything like that. The whole controller is responsible for creating a new
subscriber and nothing else. I use this type of controller because the API doesn't need any
other functionality. So this is a perfect opportunity to use an invokable controller.
What is the $subscriber->getData() method? In the previous chapters, you have seen
this snippet multiple times:
144 / 327
Martin Joo - Domain-Driven Design with Laravel
Subscriber!$with(['form', 'tags'])
!%orderBy('first_name')
!%get()
!%map(fn (Subscriber $subscriber) !(
SubscriberData!$from($subscriber)
);
Here we map through the subscribers and create a SubscriberData inside the callback.
We need to do this so frequently that laravel-data provides the getData helper function to
achieve the same. We need to tell the package what data class belongs to a model:
namespace Domain\Subscriber\Models;
use Domain\Subscriber\DataTransferObjects\SubscriberData;
After that, we can use the getData on any Subscriber instance, and it will return a
SubscriberData instance.
Basically, that's it; we have an API that can be called with the same request as the web
route:
145 / 327
Martin Joo - Domain-Driven Design with Laravel
{
"email": "[email protected]",
"first_name": "Test",
"last_name": "User",
"tag_ids": [1,2],
"form_id": 1
}
{
"id": 214,
"email": "[email protected]",
"first_name": "Test",
"last_name": "User",
"full_name": "Test User",
"subscribed_at": "2022-05-05 15:50:34",
"tags": [
{
"id": 1,
"title": "Laravel"
},
{
"id": 2,
"title": "Vue"
}
],
"form": {
"id": 1,
146 / 327
Martin Joo - Domain-Driven Design with Laravel
The CSV import is the next feature where DTOs and actions are beneficial. We want to
import a CSV like this:
It's a really simple CSV that contains a comma-separated list of tags. Of course, we need a
new action, the ImportSubscribersAction :
namespace Domain\Subscriber\Actions;
use Domain\Shared\Actions\ReadCsvAction;
class ImportSubscribersAction
{
public static function execute(
string $path,
User $user
): void {
ReadCsvAction!$execute($path)
!%each(function (array $row) use ($user) {
147 / 327
Martin Joo - Domain-Driven Design with Laravel
$parsed = [
!!*$row,
'tags' !( self!$parseTags($row, $user),
];
$data = SubscriberData!$from($parsed);
if (self!$isSubscriberExist($data, $user)) {
return;
}
UpsertSubscriberAction!$execute($data, $user);
});
}
}
The ReadCsvAction reads the given CSV file into a Collection of arrays. It returns a
Collection that contains arrays. Every array represents a row from the CSV, so it has the
following shape:
[
'email' !( '[email protected]',
'first_name' !( 'John',
'last_name' !( 'Doe',
'tags' !( 'Laravel,Vue,nodejs',
]
148 / 327
Martin Joo - Domain-Driven Design with Laravel
I won't list the ReadCsvAction here. It's a very basic script that uses PHP's native
fgetcsv function and a while loop to read the file's contents.
After reading the CSV, the action goes through every item and parses the tags. It
means:
149 / 327
Martin Joo - Domain-Driven Design with Laravel
/**
* @param string[] $row
* @param User $user
* @return Tag[]
!"
private static function parseTags(array $row, User $user):
array
{
$tags = collect(explode(',', $row['tags']))
!%filter()
!%toArray();
It explodes the string by commas, so we get an array like this: ['Laravel', 'Vue'] . If you
call the filter without any callback, it will filter out null values from the collection,
which is very helpful in this case. After that, we are ready to query existing tags and insert
new ones:
/**
* @param string[] $tags
* @param User $user
* @return Tag[]
!"
private static function getOrCreateTags(
array $tags,
User $user
): array {
return collect($tags)
150 / 327
Martin Joo - Domain-Driven Design with Laravel
The firstOrCreate will run a select query if a record is found by the given attributes, or
it will run an insert query if it's not found. This action will run from the command line
where we don't have a request, so this is why the array has a user_id key (if you
remember, the UserScope works only if there's a request).
namespace App\Console\Commands\Subscriber;
use Domain\Subscriber\Jobs\ImportSubscribersJob;
151 / 327
Martin Joo - Domain-Driven Design with Laravel
ImportSubscribersJob!$dispatch(
storage_path('subscribers/subscribers.csv'),
User!$findOrFail($userId),
);
return self!$SUCCESS;
}
}
I like to use only optional arguments in commands. If it's null , I call the ask method
to ask for input. This way, you can call any command without thinking about arguments.
The command will ask them.
There's an ImportSubscribersJob . The import is being queued because it can be a
heavy and long-running task in the case of a large CSV file.
The command is located in the App\Console namespace where the console
application lives.
152 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\Jobs;
use Domain\Subscriber\Actions\ImportSubscribersAction;
It calls the import action, and that's it. This class is required only to be able to queue the
import process. It's inconvenient to have one more class, but this is how Laravel works, so
there's not much to do.
Now let's see how we can use this import action from the web application. As I showed you
earlier, I used a resource controller for the subscribers. Now I need to add an import
functionality that is not part of the standard resource methods. For these features, I like to
use invokable controllers. So the routes look like this in the web.php :
153 / 327
Martin Joo - Domain-Driven Design with Laravel
Route!$middleware(['auth', 'verified'])!%group(function () {
Route!$resource('subscribers', SubscriberController!$class);
Route!$post(
'subscribers/import',
ImportSubscribersController!$class
);
}
I find this technique suitable for most of my projects. Of course, there's the disadvantage of
having both resource and invokable controllers simultaneously, which can be annoying.
What are the alternatives?
You only have invokable controllers. Personally, I don't like this approach too much.
One of the disadvantages of using DDD is that you have many classes. If you're using
invokable controllers, you have even more of them. And controllers, actions,
commands, and jobs will have very similar names, which can be hard to navigate.
You only have resource controllers, and you write custom methods. So you have a
SubscriberController with an import method. This works fine for the first few
custom methods. After that, controllers will grow very large very soon. So I try to avoid
this approach. A resource controller has seven methods by default. In my opinion,
that's enough.
So this is why I'm using both resource and invokable controllers. As you can expect, the
ImportSubscribersController is very simple:
154 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace App\Http\Web\Controllers\Subscriber;
use Domain\Subscriber\Jobs\ImportSubscribersJob;
class ImportSubscribersController
{
public function !&invoke(Request $request): Response
{
ImportSubscribersJob!$dispatch(
storage_path('subscribers/subscribers.csv'),
$request!%user(),
);
What does it do? It dispatches the job. What does the job do? It calls the action. I think you
got the point. Actions are great. DTOs are great. Put them together, and you have a
superpower.
155 / 327
Martin Joo - Domain-Driven Design with Laravel
Conclusion
Requests
Resources
Services
Repositories
156 / 327
Martin Joo - Domain-Driven Design with Laravel
Requests DTOs
Resources DTOs
You might be thinking this structure has a significant disadvantage: right now, there's no
way to reuse single queries. For example, what do we do if we need to query all the
subscribers? Just like we did for the list, but this time it's going to be used in export, for
example. Right now, if you want to do something like that, you need to instantiate the whole
GetSubscribersViewModel and call the subscribers method. It's not optimal. But even
worse, it will return a Paginator instead of a Collection . Don't worry; we will write
reusable queries using custom Query Builders. But for now, they were not needed. By the
way, I like the idea that the subscribers function of the GetSubscribersViewModel
returns a Paginator . This class is specific to this very page, and it needs pagination.
157 / 327
Martin Joo - Domain-Driven Design with Laravel
Broadcasts
As a quick reminder, broadcasts and sequences live under the Mail domain. In this case, I'll
use sub-namespaces, for example:
Domain
Mail
Models
Broadcast
Broadcast.php
Sequence
Sequence.php
SequenceMail.php
Now that the app can manage subscribers, it's time to move on to broadcasts. They are the
most straightforward type of e-mail. A broadcast can be sent at any time to any subset of
subscribers (using filters). First, let's see the BroadcastData class:
namespace Domain\Mail\DataTransferObjects\Broadcast;
use Domain\Mail\Enums\Broadcast\BroadcastStatus;
use Domain\Mail\DataTransferObjects\FilterData;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Casts\EnumCast;
158 / 327
Martin Joo - Domain-Driven Design with Laravel
This is the whole class; there's no fromRequest or fromModel or rules . It's quite
simple, but there are some things worth discussing:
Every broadcast can have filters. The target audience can be narrowed by specifying
forms or tags. For example, if you want to send a broadcast to subscribers who come
from the 'Waiting List' form, you can do that with a simple form filter. I'll show you the
FilterData class in a minute.
The WithCast attribute can be used to cast properties. In this example, the EnumCast
is used. It tries to create a BroadcastStatus enum from a string. Consider the
following example:
In this case, the EnumCast tries to create an enum from the string draft . Of course,
this will only work with backed enums, and a case with the value draft must exist.
The status property has a default draft value. There's no status input field on the
create form, so we need a default value. Every broadcast is a draft by default. You can
only "change" the status by sending the broadcast to subscribers. The status will be
159 / 327
Martin Joo - Domain-Driven Design with Laravel
There are no validation rules. It's because a broadcast has only three rules:
If you think about it, all of these rules are expressed implicitly in the constructor: there is no
? before $subject or $content , and there's a BroadcastStatus type-hint. Fortunately,
laravel-data provides us with these default, implicit validations. So if you try to submit a
broadcast without a title, it won't throw you a type error (null given instead of string) but a
valid response with the validation errors. The same applies to the status.
namespace Domain\Mail\Enums\Broadcast;
160 / 327
Martin Joo - Domain-Driven Design with Laravel
Handling Filters
namespace Domain\Mail\DataTransferObjects;
As you can see, it accepts tag IDs and form IDs so the request will look like this:
{
"subject": "My Awesome Broadcast",
"filters": {
"form_ids": [1],
"tag_ids": [4,9]
}
}
As discussed earlier, the filters column's type is JSON, so the whole object with the two
arrays will be saved. This can cause some problems, for example:
161 / 327
Martin Joo - Domain-Driven Design with Laravel
$broadcast = Broadcast!$first();
echo $broadcast!%filters;
Of course, we can use an array cast in the Broadcast model to have an array:
[
"tag_ids" !( [1],
"form_ids" !( [4,9],
]
But the whole point of using DTOs (or at least partly) is to avoid working with unstructured
arrays. Fortunately, in Laravel, we can write our own cast. This is the FiltersCast :
namespace Domain\Mail\Models\Casts;
return $filterArray
? FilterData!$from($filterArray)
: FilterData!$from(FilterData!$empty());
}
162 / 327
Martin Joo - Domain-Driven Design with Laravel
/**
* @param FilterData $value
!"
public function set($model, string $key, $value, array
$attributes)
{
return [
'filters' !( json_encode($value),
];
}
}
get will run when we access the property. It decodes the raw JSON data and returns
a new FilterData instance. I'll explain what is the FilterData::empty in just a
minute.
set will run when we set the property. As you can see in the doc block, the $value is
a FilterData in this case.
Now that we have the cast, we need to use it in the Broadcast model:
163 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Models\Broadcast;
use Domain\Mail\DataTransferObjects\Broadcast\BroadcastData;
use Domain\Mail\Models\Casts\FiltersCast;
use Domain\Mail\Enums\Broadcast\BroadcastStatus;
use Domain\Mail\DataTransferObjects\FilterData;
protected $casts = [
'filters' !( FiltersCast!$class,
'status' !( BroadcastStatus!$class,
];
protected $attributes = [
'status' !( BroadcastStatus!$Draft,
];
}
164 / 327
Martin Joo - Domain-Driven Design with Laravel
$broadcast!%filters = FilterData!$from($request!%filters);
echo $broadcast!%filters;
You might be thinking: "Why to bother with a separate cast class? The same can be done
using an attribute accessor, right?". Yes, it's true. It's possible to use an accessor
something like:
It works the same way, just as the custom cast. Although I like the attribute better, we know
that an e-mail in a sequence will also have filters. So by moving this logic into a cast class,
we can reuse it later in the sequence mail model. And in any other future class that needs
filters.
One last note before moving on. As you can see, the FilterData has no ID property. This
is obvious since a filter is not an "entity" or a model. It's just a container for tag and form
IDs. In the introduction, I said that the main difference between a value object and a DTO is
that a DTO always has an ID, but a value object never has one. I also said that I would
165 / 327
Martin Joo - Domain-Driven Design with Laravel
In the context of Larave and laravel-data, using a Data class is so much easier to
handle filters.
Nesting the FiltersData inside the BroadcastData works automatically.
laravel-data will handle the casting automatically. We can just do this:
BroadcastData::from($request->all()) and it will work.
We can inject the data class in a controller method (as we did earlier), and it will work.
Validation is handled out-of-the-box if we need some.
So in this particular example, I'll go with the practical solution instead of blindly following a
"DDD rule". As a bonus: if you need to refactor the filters and use a filters table instead
of a single column (as discussed in the design chapter), it will be easier if you already have
a FilterData class.
166 / 327
Martin Joo - Domain-Driven Design with Laravel
Upserting A Broadcast
Now, let's do something with that DTO! The broadcast form looks like this:
167 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace App\Http\Web\Controllers\Mail\Broadcast;
use Domain\Mail\Actions\Broadcast\UpsertBroadcastAction;
class BroadcastController
{
public function store(
BroadcastData $data,
Request $request
): RedirectResponse {
UpsertBroadcastAction!$execute($data, $request!%user());
return Redirect!$route('broadcasts.index');
}
return Redirect!$route('broadcasts.index');
}
}
168 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Actions\Broadcast;
use Domain\Mail\DataTransferObjects\Broadcast\BroadcastData;
use Domain\Mail\Models\Broadcast\Broadcast;
use Domain\Shared\Models\User;
class UpsertBroadcastAction
{
public static function execute(
BroadcastData $data,
User $user
): Broadcast {
return Broadcast!$updateOrCreate(
[
'id' !( $data!%id,
],
[
!!*$data!%all(),
'user_id' !( $user!%id,
],
);
}
}
169 / 327
Martin Joo - Domain-Driven Design with Laravel
These controller actions (store and update) don't require a view model. They just take care
of inserting or updating a broadcast. However, create and edit need a view model (just as
earlier with subscribers):
class BroadcastController
{
public function create(): Response
{
return Inertia!$render('Broadcast/Form', [
'model' !( new UpsertBroadcastViewModel(),
]);
}
170 / 327
Martin Joo - Domain-Driven Design with Laravel
return $this!%broadcast!%getData();
}
}
HasTags
HasForms
Since a lot of view models will need to query tags and forms, I extracted these methods into
two traits:
171 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Shared\ViewModels\Concerns;
trait HasTags
{
/**
* @return Collection<TagData>
!"
public function tags(): Collection
{
return Tag!$all()!%map!%getData();
}
}
trait HasForms
{
/**
* @return Collection<FormData>
!"
public function forms(): Collection
{
return Form!$all()!%map!%getData();
}
}
172 / 327
Martin Joo - Domain-Driven Design with Laravel
Filtering Subscribers
The next few chapters are going to be fun. Here are some of the techniques we'll use:
Without further hype, let's jump into the code. To send broadcasts, first, we need to filter
the subscribers based on the filters in the broadcast. Let's define the relationship between
filters. If you have the following setup:
Form IDs: 1
Tag IDs: 10,12
where
subscribers.form_id IN (1)
and
subscriber_tag.tag_id IN (10,12)
Or in other words:
There's an AND relation between the form and the tag filters
There's an OR relation between the IDs. Writing tag_id IN (1,2) is the same as
writing tag_id = 1 OR tag_id = 2
Filters are a topic that can require many changes in the future. Just think about some of
these features:
What if the app has some e-commerce features? For example, creators can publish
products on the platform, and subscribers can buy them if they're interested. Now we
173 / 327
Martin Joo - Domain-Driven Design with Laravel
have filters like: who purchased a product, who used a discount code, how much a
subscriber paid for products, etc.
The app does not have this feature right now, but what about custom fields for
subscribers? In this case, users can create any fields they want and populate via some
automation rules, by hand, or by CSV import. All of these fields can be used in filters.
Geolocation based on IP addresses?
What if we need to integrate with 3rd parties? Maybe these integrations also bring
some new interesting filter opportunities.
We clearly need a solution that can handle new use-cases easily. In my opinion, the strategy
pattern is our best choice here. In this pattern, every filter has a dedicated class, and
there's some kind of abstraction. Either an abstract class or an interface. As of now, we
need three classes:
Filter : This is the abstract class or interface that defines the contract of a concrete
filter class.
TagFilter : This class takes care of the tag filters.
FormFilter : And finally, a class for form filters.
These filters can be expressed as one query, so it seems logical to me that both of these
classes will append where clauses to a base query. Something like that:
!/
* At this point $query contains something like:
* select * from subscribers
* where subscribers.form_id IN (1)
!"
(new FormFilter($broadcast!%filters))!%filter($query);
174 / 327
Martin Joo - Domain-Driven Design with Laravel
It's just pseudo-code, but let's think about the idea. Can this work in other use cases?
fields : it contains every user-defined field with types (such as string, number, JSON)
and other meta information.
subscriber_field : a pivot table where every row represents the value of a custom
field for a specific subscriber.
If you think about it, this is very similar to tags. It is also implemented with two tables:
tags and subscriber_tag . So if we can handle tags with this pattern, I think custom
fields can also be handled.
Geolocation
In this situation, we probably will have some new columns in the subscribers table, such
as:
IP address
Country
Region
City
Or maybe a new table to handle multiple IP addresses, multiple locations. But the point is:
it's pretty easy.
175 / 327
Martin Joo - Domain-Driven Design with Laravel
Calling the 3rd party API every time before sending a broadcast. This is clearly a very
poor implementation. Imagine calling an API 5000 times before sending a broadcast to
5000 subscribers.
Storing the "external" data in our database. For example, integrating with an e-
commerce provider and we want to store every product purchase made by our
subscribers. In this case, it's just another table with a subscriber_id that can be
joined and filtered with a where clause.
E-commerce features
It's also tough to predict, but if we're talking about products and purchases, it's essentially
the same as storing the purchases given by a 3rd party API.
In my opinion, all of those cases can be implemented by adding a new Filter class such
as the FormFilter or the TagFilter . The main difference will be the data. In the case of
forms or tags, we'll only use IDs, but in other cases (such as custom fields or geolocation),
a more sophisticated data structure will be needed. But you can always invent some more
generic DTO class to handle such challenges.
Now, back to reality! Let's start with the FormFilter , the easiest of all:
namespace Domain\Subscriber\Filters;
use Illuminate\Database\Eloquent\Builder;
class FormFilter
{
public function !&construct(protected readonly array $ids)
{
}
176 / 327
Martin Joo - Domain-Driven Design with Laravel
There's no abstraction yet. We'll get there later. At this point, I have no idea what's the
abstraction is, so I like to start with one of the concrete implementations.
This class lives inside the Subscriber domain. I know you're reading the "Broadcasts"
chapter, but these filter classes will filter subscribers.
The filter method is pretty simple, but there are two critical things. First of all, the if
statement is very important. Without it, the following query will run:
Since it's an empty array, every subscriber will be returned. It's clearly not what we want. If
the $ids array is empty, we don't need to do anything. There's no form filter; there's
nothing to handle.
177 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\Filters;
use Illuminate\Database\Eloquent\Builder;
class TagFilter
{
public function !&construct(protected readonly array $ids)
{
}
It queries subscribers where they have tags, but only those tags specified in the $ids
array. Now we can see the abstraction:
178 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\Filters;
use Domain\Mail\DataTransferObjects\FilterData;
use Illuminate\Database\Eloquent\Builder;
The constructors are removed from the filter classes, and we have a contract. When I use
the strategy pattern and have multiple classes that implement the same contract, I usually
want some "dynamic" behavior. Let me illustrate it with some made-up code:
179 / 327
Martin Joo - Domain-Driven Design with Laravel
$query = Subscriber!$query();
namespace Domain\Subscriber\Enums;
use Domain\Subscriber\Filters\Filter;
use Domain\Subscriber\Filters\FormFilter;
use Domain\Subscriber\Filters\TagFilter;
180 / 327
Martin Joo - Domain-Driven Design with Laravel
$filter = Filters!$from('tag_ids')!%createFilter($ids);
The from function creates an enum instance from a scalar value (only works with backed
enums), and we can call methods on this instance.
Pipelines
The whole process of filtering can be imagined as a "pipeline" where we send the initial
query through the Filter instances, and each filter modifies the query. Maybe you don't
know about this, but Laravel has a concept exactly like that:
181 / 327
Martin Joo - Domain-Driven Design with Laravel
use Illuminate\Pipeline\Pipeline;
$subscribers = app(Pipeline!$class)
!%send(Subscriber!$query())
!%through($arrayOfFilters)
!%thenReturn()
!%get();
So Laravel will send the initial query through the Filter instances where both TagFilter
and FormFilter will append a where clause (and a join) to the query builder.
To use the Pipeline , we have to make a small change in the Filter classes:
182 / 327
Martin Joo - Domain-Driven Design with Laravel
return $next($subscribers);
}
return $next($subscribers);
}
}
The function is called handle . This is because Pipeline will look for a handle
method in each pipe. It can be overwritten, but I decided to go with the default.
There's a second argument. It's called next and is a type of Closure . Each pipe will
get the next pipe as an argument. So when the first Filter is being executed, let's
say the FormFilter , the $next argument will be the second filter, so the TagFilter
instance.
We call this $next variable with the query builder instance on the last line.
183 / 327
Martin Joo - Domain-Driven Design with Laravel
$subscribers!%whereIn('form_id', $this!%ids);
return $next($subscribers);
}
}
You absolutely don't need to use this approach, but it fits the current use case very well.
After all of this, let's write a class that will actually use these classes and returns the
subscribers for a broadcast:
184 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\Actions;
class FilterSubscribersAction
{
/**
* @return Collection<Subscriber>
!"
public static function execute(Broadcast $broadcast):
Collection
{
return app(Pipeline!$class)
!%send(Subscriber!$query())
!%through(self!$filters($broadcast))
!%thenReturn()
!%get();
}
/**
* @return array<Filter>
!"
public static function filters(Broadcast $broadcast): array
{
return collect($broadcast!%filters!%toArray())
!%map(fn (array $ids, string $key) !(
Filters!$from($key)!%createFilter($ids)
)
!%values()
!%all();
}
}
185 / 327
Martin Joo - Domain-Driven Design with Laravel
[
'tag_ids' !( [1,2],
'form_ids' !( [3,4],
]
So in the map $key is either tag_ids or form_ids and $ids contain the actual IDs. As
we discussed earlier, this line would create a TagFilter :
Filters!$from('tag_ids')!%createFilter([1,2]);
Since the initial collection is associative, we need to call the values method to get a
classic array with integer indexes. This array can be used with the Pipeline .
As you can see, this class is an action that returns a collection without side effects. So there
are no inserts updates or any other operation. This logic can be moved into a query builder,
for example, into the BroadcastBuilder (which doesn't exist yet). But here's the thing: I
know that sequences will use the same logic to filter subscribers. And what if we want to
use this piece of logic in other places as well? Such as:
So there are at least three classes that this logic can belong to:
Broadcast
Sequence Mail
Subscriber
186 / 327
Martin Joo - Domain-Driven Design with Laravel
Later, when we implement sequences, we'll run into some issues with this action that raises
some interesting questions and refactoring ideas. All right, it was a long chapter, so here's a
context diagram to wrap things up:
187 / 327
Martin Joo - Domain-Driven Design with Laravel
Sending A Broadcast
class SendBroadcastAction
{
public static function execute(Broadcast $broadcast): int
{
if (!$broadcast!%status!%canSend()) {
throw CannotSendBroadcast!$because(
"Broadcast already sent at {$broadcast!%sent_at}"
);
}
$subscribers = FilterSubscribersAction!$execute($broadcast)
!%each(fn (Subscriber $subscriber) !(
Mail!$to($subscriber)!%queue(new EchoMail($broadcast))
);
$broadcast!%markAsSent();
188 / 327
Martin Joo - Domain-Driven Design with Laravel
There are four parts of this function; let's go through them one by one.
Rejecting a broadcast
There's an important rule: users cannot send a broadcast already sent. In this case, the
method will throw an exception. If you remember, the Broadcast model has an enum cast:
protected $casts = [
'filters' !( FiltersCast!$class,
'status' !( BroadcastStatus!$class,
];
}
The status column has a BroadcastStatus enum cast. This enum has a method called
canSend :
namespace Domain\Mail\Enums\Broadcast;
189 / 327
Martin Joo - Domain-Driven Design with Laravel
{
return match ($this) {
self!$Draft !( true,
self!$Sent !( false,
};
}
}
In the introduction, I talked about states and transitions where each state has a dedicated
class. I also said that it could be overkill for simple problems. Now broadcast has only two
statuses and one behavior that depends on the status. In these cases, you can move these
status-dependent features into the enum. As far as it's simple like this one, it's perfectly
okay, in my opinion.
So if the status of the broadcast is sent, we throw an exception. I often use a factory
function called because that looks like:
namespace Domain\Mail\Exceptions\Broadcast;
190 / 327
Martin Joo - Domain-Driven Design with Laravel
It's just a small helper function, but it feels natural to me. Another approach is to write a
factory function for each reason:
namespace Domain\Mail\Exceptions\Broadcast;
191 / 327
Martin Joo - Domain-Driven Design with Laravel
The next part of the action is filtering the subscribers and actually sending the e-mails:
$subscribers = FilterSubscribersAction!$execute($broadcast)
!%each(fn (Subscriber $subscriber) !(
Mail!$to($subscriber)!%queue(new EchoMail($broadcast))
);
This method will probably send 1000s of e-mails, so obviously we need to queue them.
Now, what is the EchoMail ? Since we cannot have a dedicated mail for every broadcast
and sequence, we need some 'generic' mail class and mail template. So we need a mail
class and template that accepts a broadcast and just uses the $broadcast->title and
the $broadcast->subject . This is what the EchoMail looks like:
namespace Domain\Mail\Mails;
192 / 327
Martin Joo - Domain-Driven Design with Laravel
}
}
It's as easy as that. The !! makes sure that the content is rendered as HTML. Now, this
class and Blade template can be used for every broadcast. Of course, if we want to use
them to handle sequences (in the future), we need to change the parameter type for
something more generic.
Mail!$to($subscriber)
Behind the scene, this trait will look for an email column in the model to send e-mails.
193 / 327
Martin Joo - Domain-Driven Design with Laravel
The next part is changing the broadcast's status from draft to sent :
$broadcast!%markAsSent();
This method comes from the BroadcastBuilder , which is our first query builder:
namespace Domain\Mail\Builders\Broadcast;
use Domain\Mail\Enums\Broadcast\BroadcastStatus;
use Illuminate\Database\Eloquent\Builder;
A builder class works very similarly to a standard model. You can write two kinds of
methods:
In a case of a Builder , we need to use the $this->model to access the actual broadcast
instance. Later on, we'll write scope-like methods.
194 / 327
Martin Joo - Domain-Driven Design with Laravel
After that, we need to override the newEloquentBuilder method in the Broadcast model:
$broadcast!%markAsReady();
We're interacting with the BroadcastBuilder class. I created markAsReady method for
one main reason:
Of course, this function does not guarantee that this rule will be obeyed, but it's a good
start to have a dedicated method that takes care of this rule.
195 / 327
Martin Joo - Domain-Driven Design with Laravel
If you remember from the introduction, every broadcast or sequence mail will become a row
in the sent_mails table so we can query reports such as:
It's a polymorph table where the related model can be either a broadcast or a sequence
mail (later on that). I decided to go with the sendable name to avoid annoying conflicts
with Laravel's Mailable . So when we're sending a broadcast to 1500 subscribers, we
need to create 1500 rows in this table. This is exactly what the last part of the action does.
196 / 327
Martin Joo - Domain-Driven Design with Laravel
Since it's a polymorph relation, we need to use the morphMany method in the Broadcast
model:
Now we can send broadcasts to subscribers! In the action we're only using a queue to send
the e-mails. But the rest of the action is also performance heavy:
Because of that, the action needs to run in a queue. For that we need a job:
namespace Domain\Mail\Jobs\Broadcast;
use Domain\Mail\Actions\Broadcast\SendBroadcastAction;
197 / 327
Martin Joo - Domain-Driven Design with Laravel
It's very similar to the ImportSubscribersJob ; it only calls the action. Now we only need a
controller to dispatch this job:
namespace App\Http\Web\Controllers\Mail\Broadcast;
use Domain\Mail\Jobs\Broadcast\SendBroadcastJob;
class SendBroadcastController
{
public function !&invoke(Broadcast $broadcast): Response
{
SendBroadcastJob!$dispatch($broadcast);
Route!$patch(
'broadcasts/{broadcast}/send',
SendBroadcastController!$class
);
198 / 327
Martin Joo - Domain-Driven Design with Laravel
If you remember from the design chapter, we need to report the performance metrics of a
broadcast. Namely, these three metrics:
Fortunately, with the sent_mails table, these metrics are easy to calculate:
Total e-mails sent is the number of records in the sent_mails table with a given
broadcast's ID.
Open rate is the number of records where the opened_at is not null, divided by the
total number of records.
Finally, the click rate is the same but with the clicked_at column.
So it's not complicated at all. Let's start with some queries in the SentMailBuilder :
199 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Builders\SentMail;
use Illuminate\Database\Eloquent\Builder;
Now, if you think about it for a minute, we discussed in the design chapter that these stats
would be needed in the case of:
Broadcasts
Sequences
Individual sequence mails
For a minute, let's forget about sequences and focus on broadcasts and sequence mails.
We'll also need to query the total number of sent_mails in case of sequence mails. That
query would look like this:
200 / 327
Martin Joo - Domain-Driven Design with Laravel
This is essentially the same as a broadcast; the only difference is the class name itself. Why
don't we create a Sendable interface that abstracts away this tiny difference?
namespace Domain\Mail\Contracts;
interface Sendable
{
public function id(): int;
public function type(): string;
}
201 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Models\Broadcast;
use Domain\Mail\Contracts\Sendable;
namespace Domain\Mail\Builders\SentMail;
use Illuminate\Database\Eloquent\Builder;
202 / 327
Martin Joo - Domain-Driven Design with Laravel
{
return $this
!%where('sendable_id', $sendable!%id())
!%where('sendable_type', $sendable!%type())
!%count();
}
}
So we have the total number of e-mails sent. Let's move on to open rate:
This method accepts a Sendable and an integer . The integer is the total number of e-
mails sent, and so is the result of the previous query. As you can see, there is some code
duplication between the two methods. And possible, the clickRate will be very similar to
the openRate . We can extract this query to a scope:
203 / 327
Martin Joo - Domain-Driven Design with Laravel
This is the equivalent of an Eloquent scope, but we don't need to use the scope prefix
here. Now we can refactor the queries like the following:
204 / 327
Martin Joo - Domain-Driven Design with Laravel
!%whereOpened()
!%count() / $total;
}
I think it's very clear and readable. These methods can be used as:
/**
* $total = 3
* $openRate = 0.6667
* $clickRate = 0.3333
!"
}
205 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see, the open rate and click rate will be floats. I think we can do better than that.
Expressing a percentage is the perfect opportunity to use a value object. So instead of a
float, we want something like that:
A percent is a very generic value, so I have these kinds of value objects inside the Shared
domain:
namespace Domain\Shared\ValueObjects;
class Percent
{
public readonly float $value;
public readonly string $formatted;
206 / 327
Martin Joo - Domain-Driven Design with Laravel
$this!%value * 100, 1
) . '%';
}
We accept both the numerator and the denominator in the from method. It has two
benefits:
Without this factory function, we need to check for a 0 value every place we want to create
a new percentage. This object contains both the raw and the formatted value. We don't
really need this right now, but this is the way I used to write these classes.
207 / 327
Martin Joo - Domain-Driven Design with Laravel
Now we can write a class that calls these queries and put together a DTO that can be used
in controllers. Just as before with FilterSubscribersAction , it's going to be an action
class:
208 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Actions;
use Domain\Mail\DataTransferObjects\PerformanceData;
class GetPerformanceAction
{
public static function execute(
Sendable $sendable
): PerformanceData {
return new PerformanceData(
total: SentMail!$countOf($sendable),
open_rate: SentMail!$openRate($sendable, $total),
click_rate: SentMail!$clickRate($sendable, $total),
);
}
}
I created this class because performance is needed for three different models, and the app
will probably have many views and reports where these metrics need to be shown. By
writing an action, we can handle every possible use case. The PerformanceData class
only contains three properties:
209 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\DataTransferObjects;
use Domain\Shared\ValueObjects\Percent;
use Spatie\LaravelData\Data;
I'll use this action and DTO in a minute, but first, let's take a breath and add another cool
feature.
Later, when we calculate the performance of sequences, we'll run into some issues with the
GetPerformanceAction that raise some interesting questions and refactoring ideas.
210 / 327
Martin Joo - Domain-Driven Design with Laravel
Previewing A Broadcast
Wouldn't it be great if we could preview a broadcast before sending it? Luckily it's easy with
Laravel. First, let's create a route:
Route!$get(
'broadcasts/{broadcast}/preview',
PreviewBroadcastController!$class
);
Then a controller:
namespace App\Http\Web\Controllers\Mail\Broadcast;
use Domain\Mail\Mails\EchoMail;
use Domain\Mail\Models\Broadcast\Broadcast;
use Domain\Mail\ViewModels\Broadcast\PreviewBroadcastViewModel;
class PreviewBroadcastController
{
public function !&invoke(Broadcast $broadcast): Response
{
return Inertia!$render('Broadcast/Preview', [
'model' !( new PreviewBroadcastViewModel(
new EchoMail($broadcast)
),
]);
}
}
211 / 327
Martin Joo - Domain-Driven Design with Laravel
So the controller creates a new EchoMail instance with the $broadcast from the route.
Here's the view model:
namespace Domain\Mail\ViewModels\Broadcast;
use Domain\Mail\Mails\EchoMail;
use Domain\Shared\ViewModels\ViewModel;
As you can see, there's a mail property available in every Mailable class. In this mail
property, you can access:
212 / 327
Martin Joo - Domain-Driven Design with Laravel
That's exactly what we need. The last thing we need is the Broadcast/Preview Vue
component:
<template>
<Head title="Broadcast Preview" !1
<BreezeAuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-
tight">
{{ model.subject }}
!,h2>
!,template>
Vue will render the raw HTML content given to the v-html . The result is a page with the
content of the e-mail.
213 / 327
Martin Joo - Domain-Driven Design with Laravel
Get Broadcasts
The last broadcast-related feature is the list. If a broadcast is sent, we need to show the
performance too.
namespace App\Http\Web\Controllers\Mail\Broadcast;
use Domain\Mail\ViewModels\Broadcast\GetBroadcastsViewModel;
class BroadcastController
{
public function index(): Response
{
return Inertia!$render('Broadcast/List', [
'model' !( new GetBroadcastsViewModel(),
]);
}
214 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\ViewModels\Broadcast;
use Domain\Mail\Actions\GetPerformanceAction;
use Domain\Mail\DataTransferObjects\Broadcast\BroadcastData;
use Domain\Mail\Models\Broadcast\Broadcast;
use Domain\Shared\ViewModels\ViewModel;
/**
* @return Collection<int, PerformanceData>
!"
public function performances(): Collection
{
return Broadcast!$all()
!%mapWithKeys(fn (Broadcast $broadcast) !( [
$broadcast!%id !( GetPerformanceAction!$execute(
$broadcast
),
]);
}
}
215 / 327
Martin Joo - Domain-Driven Design with Laravel
You have a clear benefit if you're using a Trait instead of the GetPerformanceAction here.
You don't need to loop through every broadcast and store the performances as an array.
The trait itself can define an attribute accessor that loads the performance into a
performance attribute. And you can just use this attribute on the frontend. It won't cause
performance issues because attribute accessors are evaluated lazily. This means that they
won't be called until you actually access them. This also means that a simple
Broadcast::all() won't load them, and the FE won't be able to show the performances.
You can solve this issue quickly by using the append method:
Broadcast!$all()!%each!%append('performance');
Now back to the actual feature. Since it lists every broadcast, we need the performance for
every broadcast, so the resulting collection looks like this:
[
12 !( [
'total' !( 43,
'open_rate' !( [
'value' !( 0.494,
'formatted' !( '49.4%',
],
'click_rate' !( [
'value' !( 0.121,
'formatted' !( '12.1%',
],
],
]
216 / 327
Martin Joo - Domain-Driven Design with Laravel
Where index 12 is a broadcast ID. We need to display these values in a table like this:
Since we will also need something very similar for sequences and sequence mails, it's a
good idea to create a Vue component called PerformanceLine :
<script>
export default {
name: 'PerformanceLine',
props: {
performance: {
type: Object,
required: true,
},
},
}
217 / 327
Martin Joo - Domain-Driven Design with Laravel
</script>
<template>
<div class="text-sm text-gray-900">
{{ performance.total }} Recipients •
{{ performance.open_rate.formatted }} Open rate •
{{ performance.click_rate.formatted }} Click rate
</div>
</template>
218 / 327
Martin Joo - Domain-Driven Design with Laravel
!,tr>
Sequences
Congratulations! At this point, you have learned the basics:
Creating models
Updating models
Combine these two actions using upsert
Listing models
Using DTOs in these scenarios
How view models work
A lot of actions
So in this chapter, I try to avoid the basics and focus on the more exciting stuff. First, let's
recap the database structure:
Everything is discussed in the design chapter except one part, the sequence_subscriber .
219 / 327
Martin Joo - Domain-Driven Design with Laravel
When we are dealing with sequence reports, we need numbers like these:
Total subscribers: each subscriber who received at least one e-mail from this sequence
is considered to be subscribed to the sequence.
In Progress: each subscriber is currently receiving e-mails from this sequence.
Completed: each subscriber who finished the sequence. So they will not receive any
more e-mails from this sequence.
We need the sequence_subscriber pivot table with a status column to calculate these
numbers. The app will attach every subscriber to the sequence when creating a new one.
So if there are 100 subscribers in the database when creating a new sequence, 100 rows
will be inserted in the sequence_subscriber table with a default null status. null
means that the subscriber hasn't received any e-mail from the sequence yet.
A scheduled command will send the e-mails in the sequence. When this command is
running, it will update this status. This is how can keep track of the subscriber statuses.
What happens with new subscribers? You create a sequence with 100 subscribers, and
after a day, you have ten new subscribers. Now they're not in the sequence_subscriber
table, so will they miss the sequence? Of course not. We'll take care of them later.
220 / 327
Martin Joo - Domain-Driven Design with Laravel
Creating A Sequence
Creating a sequence is a bit different from creating a broadcast, for example. I recommend
you try out the demo application. If you create a new sequence, you'll only need to provide
a name:
After you provide a name, you are redirected to a page where you can add/edit/remove
mails in the sequence:
221 / 327
Martin Joo - Domain-Driven Design with Laravel
One interesting thing: when you create a new sequence, the app will automatically create
the first sequence mail with default data that you can see on the screenshot.
namespace Domain\Mail\Actions\Sequence;
class CreateSequenceAction
{
public static function execute(
SequenceData $data,
User $user
): Sequence {
return DB!$transaction(function () use ($data, $user) {
$sequence = Sequence!$create([
!!*$data!%all(),
'user_id' !( $user!%id,
]);
UpsertSequenceMailAction!$execute(
SequenceMailData!$dummy(),
$sequence,
$user
);
$sequence!%subscribers()!%sync(
Subscriber!$select('id')!%pluck('id')
);
return $sequence;
});
222 / 327
Martin Joo - Domain-Driven Design with Laravel
}
}
As discussed earlier, the last step of this action inserts every subscriber into the
sequence_subscriber table without a status.
You can see I'm using the SequenceMailData::dummy method, which is a factory function
that returns a new SequenceMailData with some default data:
This is how a 'dummy' mail is created. I extracted this function because later, we'll reuse it.
223 / 327
Martin Joo - Domain-Driven Design with Laravel
There's a method called empty in every Data class provided by laravel-data. It returns an
array with the shape of your DTO, but every attribute will have a null value. It's very useful
in some situations. So the SequenceMailScheduleAllowedDaysData::empty will return this
array:
[
'monday' !( null,
'tuesday' !( null,
'wednesday' !( null,
'thursday' !( null,
'friday' !( null,
'saturday' !( null,
'sunday' !( null,
]
[
'monday' !( true,
'tuesday' !( true,
'wednesday' !( true,
'thursday' !( true,
'friday' !( true,
'saturday' !( true,
'sunday' !( true,
]
224 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\DataTransferObjects\Sequence;
use Spatie\LaravelData\Data;
In this case, where the DTO has only bool properties, this is a more reasonable default
instead of null values. As promised, I don't think we need to spend much more time on
CRUD actions or anything like that (by the way, you can always check the source code for
more details). The interesting stuff is how the app will start and proceed in a sequence.
225 / 327
Martin Joo - Domain-Driven Design with Laravel
Proceeding A Sequence
First, let's go through what it means to start and proceed with a sequence step by step:
A scheduled job will run every minute (or so) and initiate the whole process for every
published sequence. From here on, everything is meant for one sequence.
Apply the filters set up for the mail. The FilterSubscribersAction will be used
for this.
Check that the mail should be sent based on the schedule. For example, if the
allowed_days contains only weekdays and the current day is Saturday, we don't
need to send this mail.
Check if it's not too early for the mail to be sent. The schedule contains a delay
attribute that says something like: "this mail can only be sent after two days since
the last mail".
Finally, we need to check that the given subscriber has not already received this
mail.
After all these, we can queue the actual e-mails.
As a final step, this action will also update the sequence_subscriber status column to
either in progress or completed.
226 / 327
Martin Joo - Domain-Driven Design with Laravel
I'll cover the scheduled command later. Now, let's focus on the code that deals with a
single sequence and its mails. This logic will live inside an action, and a good name can be
ProceedSequenceAction . But first, let's implement the logic inside the "filter the
subscribers" box.
227 / 327
Martin Joo - Domain-Driven Design with Laravel
Let's write a simple method that determines if the SequenceMail should be sent at the
current date based on its schedule:
I don't put this method inside a query builder simply because it's not a query. It's in the
SequenceMail model. I'm not a big fan of "fat models," but they can still have some small
methods.
return $this!%schedule!%allowed_days!%monday;
By default, if you're using the array cast provided by Laravel, the allowed_days would
be an array. And this line would throw a huge exception in your face. To be able to use it as
an object, I have a custom cast that casts the attribute into a DTO:
228 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Models\Casts\Sequence;
It works the same way as discussed in the broadcast chapter (with filters). This way, the
allowed_days attribute will return a SequenceMailScheduleAllowedDaysData DTO, and
we can use it as an object.
229 / 327
Martin Joo - Domain-Driven Design with Laravel
Already received
Next, we need a method that determines if the subscriber has already received a given mail.
The sent_mails table has a subscriber_id , which is how we can check it. So first, I'll
add a relationship to the Subscriber :
namespace Domain\Subscriber\Models;
How you name this method is an interesting questions. I see three options:
sent_mails . This is the most obvious one since it's a relationship to the SentMail
model. And it's technically correct. However, writing $subscriber->sent_mails feels
very weird to me. It's like saying the "the subscriber sent these mails". But subscribers
do not send mails, they receive them. And even from technical point of view it can be
weird in certain situations, for example:
Subscriber!$query()
!%whereNotNull('form_id')
!%get()
!%map!%sent_mails
!%map!%getData()
230 / 327
Martin Joo - Domain-Driven Design with Laravel
When you have snippets like this, it feels weird to see the word sent_mails in the context
of subscribers. I need to think about it. Even if it's just one second, it's an extra cognitive
load.
received_mails . From a technical point of view, it's incorrect and a bit weird. The
method is called received_mails , but it references the sent_mails table. However,
from a domain language perspective, this is the correct name, in my opinion. Imagine a
feature request from a PM: "Can we list all the mails the subscriber has received when
I click on his profile?" You say yes (not if you have a choice) and can do the following:
So I've decided to go with received_mails . Using this relation, we can write the following
method:
231 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\Builders;
By using the received_mails relationship, we can call the whereSendable scope, and
we're basically done.
The last check we need to perform before sending a sequence mail to a subscriber is to
determine if it's too early based on the schedule. Just a quick reminder, here are the
important models to determine this:
delay the number of time unit has to pass before sending the given SequenceMail .
unit is either day or hour .
The unit attribute has an enum cast which refers to this enum:
232 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Enums\Sequence;
Since we can write methods in enums, this is the perfect place to add a small helper
function:
namespace Domain\Mail\Enums\Sequence;
It returns the time passed since the specific date. Either in days or hours.
233 / 327
Martin Joo - Domain-Driven Design with Laravel
With this simple helper method, we determine if enough time has passed to send a
SequenceMail :
If the schedule has a unit of day, both the delay and the result of timePassedSince are in
days. This method can be used as:
$sequenceMail!%enoughTimePassedSince(
$subscriber!%received_mails()!%last()
);
Using this method, we can determine if the SequenceMail can be sent or not. However, it
would be very useful (you'll see in a minute) if we could do something like that:
$subscriber!%tooEarlyFor($sequenceMail);
So let's make it possible. First, we need (don't really need, but it's a nice addition) a special
relationship in the Subscriber model:
234 / 327
Martin Joo - Domain-Driven Design with Laravel
All right. Take a deep breath, and let me summarize these methods in one snippet:
235 / 327
Martin Joo - Domain-Driven Design with Laravel
236 / 327
Martin Joo - Domain-Driven Design with Laravel
237 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Actions\Sequence;
class ProceedSequenceAction
{
public static function execute(Sequence $sequence): int
{
foreach ($sequence!%mails()!%wherePublished()!%get() as
$mail) {
$subscribers = self!$subscribers($mail);
!) !!*
}
}
return FilterSubscribersAction!$execute($mail)
!%reject!%alreadyReceived($mail)
!%reject!%tooEarlyFor($mail);
}
}
238 / 327
Martin Joo - Domain-Driven Design with Laravel
These are the three methods that will determine if a SequenceMail can be sent to a
Subscriber and this is how they are being used. Now you can see why I created the
tooEarlyFor method in the Subscriber method.
A quick note before moving on. These methods return bool values. If you always use the
is , has , should , can prefixes, They become:
shouldSendToday or canSendToday
hasAlreadyReceived
isTooEarlyFor
Changes in FilterSubscribersAction
Now that we use the same action with sequence mails, we need to make a small change.
Right now, this action accepts a Broadcast . To filter sequence mails, too, it needs to
accept a Sendable:
class FilterSubscribersAction
{
/**
* @return Collection<Subscriber>
!"
public static function execute(Sendable $mail): Collection
{
return app(Pipeline!$class)
!%send(Subscriber!$query())
!%through(self!$filters($mail))
!%thenReturn()
!%get();
}
239 / 327
Martin Joo - Domain-Driven Design with Laravel
/**
* @return array<Filter>
!"
public static function filters(Sendable $mail): array
{
return collect($mail!%filters()!%toArray())
!%map(fn (array $ids, string $key) !(
Filters!$from($key)!%createFilter($ids)
)
!%values()
!%all();
}
}
It does not require too much change, but this line collect($mail->filters()-
>toArray()) requires a filters method in the Sendable interface. It's a very small
change:
namespace Domain\Mail\Contracts;
use Domain\Mail\DataTransferObjects\FilterData;
interface Sendable
{
public function id(): int;
public function subject(): string;
public function content(): string;
public function type(): string;
public function filters(): FilterData;
}
240 / 327
Martin Joo - Domain-Driven Design with Laravel
Both Broadcast and SequenceMail implement this interface by returning some very
basic attributes. So it can be a good idea to promote it to an abstract class that implements
these methods by default. This way, you can remove duplicated code from Broadcast and
SequenceMail .
It's important to note that a trait doesn't make sense in this scenario for the following
reasons:
First, a trait can be used to add behavior to a class. Sort of like a "plug and play." In this
case, the Sendable interface has no behavior. It's just an abstraction above two
similar yet different classes. So conceptually, a trait doesn't make any sense.
But more importantly, you cannot type-hint a trait. So a method cannot have a
HasUser argument, for example.
241 / 327
Martin Joo - Domain-Driven Design with Laravel
foreach ($sequence!%mails()!%wherePublished()!%get() as
$mail) {
$subscribers = self!$subscribers($mail);
$mail!%sent_mails()!%create([
'subscriber_id' !( $subscriber!%id,
'user_id' !( $sequence!%user!%id,
]);
}
$sentMailCount += $subscribers!%count();
}
return $sentMailCount;
}
After filtering the subscribers, it queues the e-mails and creates the sent_mails records.
After sending a sequence mail, it also counts the number of subscribers so it can return the
total number of e-mails that have been sent.
To use the EchoMail with a SequenceMail we need to make a small change. It needs to
accept a Sendable as its argument. Broadcasts and sequence mails are both
implementing the Sendable interface.
242 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Mails;
And in the Blade template, we can use the content() method defined in the Sendable
interface:
Now the EchoMail and the template should work with both broadcasts and sequence
mails.
In the first foreach , we query only the published mails for the given sequence. A simple
scope implements it in the SequenceMailBuilder class:
243 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Builders\Sequence;
use Domain\Mail\Enums\Sequence\SequenceMailStatus;
use Illuminate\Database\Eloquent\Builder;
Now the app can proceed with a sequence. However, we still need a command that triggers
this action.
244 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace App\Console\Commands\Mail;
use Domain\Mail\Jobs\Sequence\ProceedSequenceJob;
use Domain\Mail\Models\Sequence\Sequence;
use Domain\Mail\Enums\Sequence\SequenceStatus;
use Illuminate\Console\Command;
return self!$SUCCESS;
}
}
It queries published sequences only dispatches the ProceedSequenceJob . This job is only
a placeholder that triggers the action. It's required to queue the actions.
245 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Jobs\Sequence;
use Domain\Mail\Actions\Sequence\ProceedSequenceAction;
246 / 327
Martin Joo - Domain-Driven Design with Laravel
247 / 327
Martin Joo - Domain-Driven Design with Laravel
Refactoring
Earlier I lied to you. Yet the FilterSubscribersAction won't work the way we expect.
There's a problem with this function:
/**
* @return Collection<Subscriber>
!"
public static function execute(Sendable $mail): Collection
{
return app(Pipeline!$class)
!%send(Subscriber!$query())
!%through(self!$filters($mail))
!%thenReturn()
!%get();
}
The problem is the Subscriber::query() call. A sequence (and a sequence mail) has its
own audience in the sequence_subscriber table. So by calling Subscriber::query() ,
we throw out of the window the actual audience and simply query everyone. Fortunately, it's
a straightforward fix. In the case of a SequenceMail , we need to use the sequence's
audience:
248 / 327
Martin Joo - Domain-Driven Design with Laravel
/**
* @return Collection<Subscriber>
!"
public static function execute(Sendable $mail): Collection
{
$subscribers = Subscriber!$query();
return app(Pipeline!$class)
!%send($subscribers)
!%through(self!$filters($mail))
!%thenReturn()
!%get();
}
If you have only one if that looks like some "workaround," I think it's okay. Generally,
it's not the end of the world. If this function won't change that much, and you won't
have other use-cases and more "workarounds," it's okay. At the end of the day, it's just
a single if statement.
249 / 327
Martin Joo - Domain-Driven Design with Laravel
When you need another instanceof or any other "hack," that's the exact moment I
would start refactoring.
But to show you an example, this is how you can refactor such a thing. First of all, what's
the cause of the "life-hack"? It's the difference between the audience of a Broadcast and
a SequenceMail :
By defining the problem, we got a new concept: the audience. It seems to me that an
audience can be another abstraction in the Sendable interface:
namespace Domain\Mail\Contracts;
use Domain\Mail\DataTransferObjects\FilterData;
interface Sendable
{
public function id(): int;
public function subject(): string;
public function content(): string;
public function type(): string;
public function filters(): FilterData;
public function audience(): Builder;
}
I like this interface. It describes what defines a 'sendable mail' perfectly in our application:
It has a subject line. Subscribers will see this in their e-mail client.
It has content. The purpose of e-mail marketing software is content, right?
It has a type because we have more than one thing that can be sent to subscribers.
250 / 327
Martin Joo - Domain-Driven Design with Laravel
It sounds great, isn't it? But now we have another problem. A sendable has:
Filters
But also an audience?
It isn't very clear. Is the audience contain only filtered subscribers? No, and as a bonus
confusion, it returns a Builder. Hmm, maybe it's just the name that is confusing. Let's try a
different one:
interface Sendable
{
public function id(): int;
public function subject(): string;
public function content(): string;
public function type(): string;
public function filters(): FilterData;
public function query(): Builder;
}
Now, what the heck is a query? It can be anything, basically. So the problem is not the
name. It's the fact that we're using an action to filter the subscribers. Unfortunately, the
FilterSubscribersAction has to die.
We wouldn't have this problem if the filter logic was implemented in the models. It would
look like this:
251 / 327
Martin Joo - Domain-Driven Design with Laravel
interface Sendable
{
public function id(): int;
public function subject(): string;
public function content(): string;
public function type(): string;
public function audience(): Collection;
}
252 / 327
Martin Joo - Domain-Driven Design with Laravel
But of course, we don't want to duplicate the code in two models, and generally speaking, I
try to avoid overloading models with query logic. Does any of it ring a bell? We want to
"plug" some behavior into specific models. Yeah, it's a trait! Since we've discovered the
concept of an audience, let's call it HasAudience :
namespace Domain\Subscriber\Models\Concerns;
trait HasAudience
{
/**
* @return Collection<Subscriber>
!"
public function audience(): Collection
{
$filters = collect($this!%filters!%toArray())
!%map(fn(array $ids, string $key) !(
Filters!$from($key)!%createFilter($ids)
)
!%values()
!%all();
return app(Pipeline!$class)
!%send($this!%audienceQuery())
!%through($filters)
!%thenReturn()
!%get();
}
}
253 / 327
Martin Joo - Domain-Driven Design with Laravel
The model that uses the trait has to have a filters attribute.
And also an audienceQuery method.
It's kind of a "standard thing" to have these assumptions when people are working with
traits, but I don't really like it. It's simply not strict enough and can cause unnecessary bugs.
Did you know that you can write abstract methods in a trait? This solves the issue and
looks very neat:
254 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\Models\Concerns;
trait HasAudience
{
abstract public function filters(): FilterData;
abstract protected function audienceQuery(): Builder;
/**
* @return Collection<Subscriber>
!"
public function audience(): Collection
{
$filters = collect($this!%filters()!%toArray())
!%map(fn(array $ids, string $key) !(
Filters!$from($key)!%createFilter($ids)
)
!%values()
!%all();
return app(Pipeline!$class)
!%send($this!%audienceQuery())
!%through($filters)
!%thenReturn()
!%get();
}
}
Now it does not have any assumptions. Instead, it defines a contract: any model that
implements this trait must define these two methods. I like it much better!
255 / 327
Martin Joo - Domain-Driven Design with Laravel
And now the difference between a Broadcast and a SequenceMail doesn't require an
if statement. But instead, the two models can define a one-liner:
256 / 327
Martin Joo - Domain-Driven Design with Laravel
As you can see, I also moved the filters from the Sendable interface to the
HasAudience trait. Now, the Sendable looks like this:
interface Sendable
{
public function id(): int;
public function subject(): string;
public function content(): string;
public function type(): string;
}
Do you really need filters to send something? Not really. So, conceptually, it's a good thing
that it doesn't have a filters method anymore.
257 / 327
Martin Joo - Domain-Driven Design with Laravel
By the way, both classes look more natural to me using the $mail->audience() function.
258 / 327
Martin Joo - Domain-Driven Design with Laravel
Was it worth it? You can easily fool yourself by saying: "Yes because the overall architecture
of the app has become much better." But here's my question: is it really a better design?
And here's the truth: we don't know it yet. Only time will tell. If in the future we have more
scenarios when we need to filter subscribers, then yes, it might be a better design. But not
necessary; for example, if the audienceQuery is always a simple Subscriber::query
then the original action was a more straightforward solution. But if the audienceQuery is
getting more complicated with every new use case, we did the right thing.
259 / 327
Martin Joo - Domain-Driven Design with Laravel
In progress
One more task needs to be done when proceeding with a sequence. It's the status update
for a particular subscriber. Basically, there are two steps here:
Marking every subscriber as in progress when queueing the e-mails for a sequence
mail.
Marking some of the subscribers as completed after the action is done with every
sequence mail.
The first step is easy; all we need to do is a batch update for a collection of subscribers:
namespace Domain\Mail\Actions\Sequence;
class ProceedSequenceAction
{
public static function execute(Sequence $sequence): int
{
$sentMailCount = 0;
foreach ($sequence!%mails()!%wherePublished()!%get() as
$mail) {
$subscribers = self!$subscribers($mail);
self!$markAsInProgress($sequence, $subscribers);
260 / 327
Martin Joo - Domain-Driven Design with Laravel
$sentMailCount += $subscribers!%count();
}
return $sentMailCount;
}
}
After the inner loop has done queueing the e-mails and inserting sent_mails records, we
can safely mark these subscribers as in progress:
/**
* @param Sequence $sequence
* @param Collection<Subscriber> $subscribers
!"
private static function markAsInProgress(Sequence $sequence,
Collection $subscribers): void
{
SequenceSubscriber!$query()
!%whereBelongsTo($sequence)
!%whereIn(
'subscriber_id',
$schedulableAudience!%pluck('id')
)
!%update([
'status' !( SubscriberStatus!$InProgress,
]);
}
The query method doesn't do anything special; I just use it because this way, all the
where statements start on a new line.
261 / 327
Martin Joo - Domain-Driven Design with Laravel
Completed
This part is much trickier than it sounds. We have several different approaches:
We can count the number of e-mails a subscriber has received and compare it to the
number of e-mails the sequence has. This solution works, but it's a bit more
complicated than it sounds. We cannot just simply count the number of sequence mails
because each mail has a different audience. So a sequence may have eight e-mails,
and a subscriber has received six of them, but s/he has already completed the
sequence because s/he is not part of the other two mails' audience. So this is how this
approach can work:
Query every sequence mails where the subscriber is part of the audience.
Count these mails and compare them to the number of received mails.
There's another more "tricky" approach:
Query the one sequence mail with the longest delay. For example, if a sequence
has two mails, one with a three-hour delay and one with a two-day delay, the
second one has the longest delay.
Query the last mail received by the subscriber from the current sequence.
Calculate the time difference between the current date and the latest received mail.
If this difference is greater than the longest delay (from step #1), the subscriber has
completed the sequence.
We're going with the first "proper" approach. I just wanted to give you an idea. Sometimes
it's good if you try to think about other, alternative solutions to a problem. In my experience,
they might give you a better solution from a performance point of view.
So let's jump in. It's not going to be easy, but hey, you wanted to learn about complicated
topics, so here we go. This is what the refactored ProceedSequenceAction looks like:
262 / 327
Martin Joo - Domain-Driven Design with Laravel
class ProceedSequenceAction
{
/**
* @var array<int, array<int!3
!"
private static array $mailsBySubscribers = [];
self!$addMailToAudience($audience, $mail);
self!$markAsInProgress($sequence, $schedulableAudience);
}
self!$markAsCompleted($sequence);
}
}
Let's go through it step by step. The first step is the audience method:
263 / 327
Martin Joo - Domain-Driven Design with Laravel
/**
* @return array<Collection<Subscriber!3
!"
private static function audience(SequenceMail $mail): array
{
$audience = $mail!%audience();
if (!$mail!%shouldSendToday()) {
return [$audience, collect([])];
}
$schedulableAudience = $audience
!%reject!%alreadyReceived($mail)
!%reject!%tooEarlyFor($mail);
I'll explain why we need to distinguish these two sets of subscribers in a minute. The next
method is the sendMails :
264 / 327
Martin Joo - Domain-Driven Design with Laravel
$mail!%sent_mails()!%create([
'subscriber_id' !( $subscriber!%id,
'user_id' !( $sequence!%user!%id,
]);
}
}
It queues the mails and creates the sent_mails records. There are two caveats here:
I'm using the schedulableAudience for the variable name even though it's just an
audience from the function's perspective. However, when I'm browsing the whole class, it's
very useful to see exactly what kind of audience it is. This is one of the hidden benefits of
actions. If this function were inside a model, I would have no idea what the
schedulableAudience means. The concept is out of context in a model with dozens of
other methods.
265 / 327
Martin Joo - Domain-Driven Design with Laravel
/**
* @param Collection<Subscriber> $audience
!"
private static function addMailToAudience(
Collection $audience,
SequenceMail $mail
): void {
foreach ($audience as $subscriber) {
if (!Arr!$get(self!$$mailsBySubscribers, $subscriber!%id))
{
self!$$mailsBySubscribers[$subscriber!%id] = [];
}
self!$$mailsBySubscribers[$subscriber!%id][] = $mail!%id;
}
}
[
12 !( [1,2,3],
15 !( [2,3],
];
The keys are Subscriber IDs, and the values are SequenceMail IDs. Why do we need an
array like this?
Later on, when we want to mark subscribers as completed, we need something that
tells us how many SequenceMail the subscriber should receive from the current
Sequence . This array is that something.
266 / 327
Martin Joo - Domain-Driven Design with Laravel
The first argument contains the total audience for a SequenceMail . The collection that
HasAudience returns.
In the example array, there are three mails for subscriber ID 12, right? But it doesn't
mean that subscriber 12 has received all three mails. It means s/he should receive
these mails at some point.
If we query the $subscriber->received_mails for the current sequence and the
count is three, it means the subscriber completed the sequence!
/**
* @param Sequence $sequence
* @param Collection<Subscriber> $schedulableAudience
!"
private static function markAsInProgress(
Sequence $sequence,
Collection $schedulableAudience
): void {
$sequence
!%subscribers()
!%whereIn(
'subscriber_id',
$schedulableAudience!%pluck('id')
)
!%update([
'status' !( SubscriberStatus!$InProgress,
]);
}
267 / 327
Martin Joo - Domain-Driven Design with Laravel
It just updates every subscriber's status to in progress. Once again, it works with the
schedulableAudience , so every subscriber who received an e-mail. They are progressing
through the sequence for sure.
And the last step is to mark the subscribers who have completed the sequence:
$completedSubscriberIds = [];
foreach (self!$$mailsBySubscribers as $subscriberId !(
$mailIds) {
$subscriber = $subscribers[$subscriberId];
268 / 327
Martin Joo - Domain-Driven Design with Laravel
SequenceSubscriber!$query()
!%whereBelongsTo($sequence)
!%whereIn('subscriber_id', $completedSubscriberIds)
!%update([
'status' !( SubscriberStatus!$Completed,
]);
}
Earlier the addMailToAudience function has collected all of the subscriber IDs. The first
part of the markAsCompleted function uses this array to get every subscriber in one
database query eager loaded with the count of sent_mails . These things are important
because it's very easy to cause N+1 query problems. I'm also using the mapWithKeys . This
is unnecessary, but it's essential from a performance point of view. I'll explain it in a minute.
The second part of the function will collect every subscriber that can be marked as
completed. Or in other words, they received every mail from the sequence they should
receive. Once again, the $mailsBySubscribers array contains every Subscriber ID that
is part of the audience of any mail of the sequence. It also contains the SequenceMail IDs
that were sent to the subscriber. Here's the reason why I used mapWithKeys earlier.
Without it, this line $subscriber = $subscribers[$subscriberId]; would look like this:
If the $subscribers collection contains 50000 items, this where will run a loop potentially
50000 times. And it's already inside a foreach ! This foreach can also run 50000 times.
So in this scenario, we could end up in a situation where we scan the $subscribers array
2 500 000 000 times. That's 2.5 billion with a B. This is called quadratic time complexity and
can be expressed as O(n^2) . I use the $subscriber collection as a hash map to avoid
that. This is beneficial because
269 / 327
Martin Joo - Domain-Driven Design with Laravel
$subscriber = $subscribers[$subscriberId];
This line has constant time complexity. So there's no extra loop to get the subscriber; it's as
simple as accessing an array key. This can be expressed as O(1) . But this line is still
inside a for loop, so the real complexity is O(n) or linear.
The key takeaway: try to avoid any expensive operation inside a foreach . Expensive
operations can be:
Querying the database. Pay extra attention to avoid accessing relationships that are not
eager-loaded. This is why I used the withCount('received_mails') before the loop.
Looping through huge collections. Let's say you have a collection with 10000 items and
a where call runs for 100ms. Nothing out of the ordinary, right? Now put this 100ms
operation inside a foreach that loops through another 10000 items. 10000 * 100ms is
exactly 1000 seconds or 16 hours. That's right; the quadratic time complexity is no
joke. And as you can see, you don't need database queries to cause trouble.
"Tricky" array functions. For example, ąrray_splice or anything else that will move a
part of the array from an index to another one. They are relatively expensive compared
to a simple $items[] = $item;
The obvious ones: sending e-mails synchronously, accessing files, making HTTP
requests, checking permissions from the database.
Now that we've discussed this one single line, let's move on to the next important one:
It determines if a subscriber can be marked as completed or not. Please note that I'm not
firing an update query inside the if. Instead, I'm collecting the IDs to avoid N+1 query
problems. And this brings us to the last part, where the actual update is happening:
270 / 327
Martin Joo - Domain-Driven Design with Laravel
SequenceSubscriber!$query()
!%whereBelongsTo($sequence)
!%whereIn('subscriber_id', $completedSubscriberIds)
!%update([
'status' !( SubscriberStatus!$Completed,
]);
Although using the $mailsBySubscribers array isn't the most elegant solution, it allows us
to update every subscriber with only two database queries. This is a class where
performance is more important than clean code.
271 / 327
Martin Joo - Domain-Driven Design with Laravel
$completedSubscriberIds = [];
foreach (self!$$mailsBySubscribers as $subscriberId !(
$mailIds) {
$subscriber = $subscribers[$subscriberId];
SequenceSubscriber!$query()
!%whereBelongsTo($sequence)
!%whereIn('subscriber_id', $completedSubscriberIds)
!%update([
'status' !( SubscriberStatus!$Completed,
]);
}
272 / 327
Martin Joo - Domain-Driven Design with Laravel
This is not the best method from a clean code perspective, but this is another hidden
benefit of actions. This is an entirely isolated class that does only one thing. In this one
class, the logic is perfectly understandable and can be maintained easily. For example, if
this function were inside a model, it would be much harder to understand what's going on,
especially with the $mailsBySubscribers array.
And lastly, a quick note about the get and set prefixes. Take a look at this function:
self!$addMailToAudience($audience, $mail);
self!$markAsInProgress($sequence, $schedulableAudience);
}
self!$markAsCompleted($sequence);
}
If you're in the mindset of not having setters at all, it's very easy to understand this.
audience is a getter, and everything else is an "action."
273 / 327
Martin Joo - Domain-Driven Design with Laravel
Sequence
Sequence Mail
As I said in the design chapter, the performance of a sequence mail can be measured in
isolation, similar to a broadcast. But a sequence's performance can also be measured as
the "container" of the sequence mails. You can take a look at this screenshot:
In the upper left corner, you can see the overall performance of the whole sequence;
meanwhile, in the list, you can see each mail's performance.
namespace Domain\Mail\Actions;
use Domain\Mail\Contracts\Sendable;
use Domain\Mail\Models\SentMail;
use Domain\Mail\DataTransferObjects\PerformanceData;
class GetPerformanceAction
{
274 / 327
Martin Joo - Domain-Driven Design with Laravel
And the good news is: it requires no change to work with SequenceMail since it also
implements the Sendable interface. The bad news is: it won't work with a Sequence for
these reasons:
275 / 327
Martin Joo - Domain-Driven Design with Laravel
Does any of it ring a bell? This is the same problem we encountered when dealing with the
FilterSubscribersAction . There's a pattern here: whenever you're working with an
action that deals with multiple types of models, using traits and polymorphism might be a
better solution.
And in general, writing actions to get some data might not be the best solution. There's
nothing wrong with getter actions, but as you can see in this particular example, they cause
us some trouble.
276 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\Models\Concerns;
use Domain\Mail\DataTransferObjects\PerformanceData;
use Domain\Shared\ValueObjects\Percent;
use Illuminate\Database\Eloquent\Relations\Relation;
trait HasPerformance
{
abstract public function performance(): PerformanceData;
abstract public function sent_mails(): Relation;
As you can see, I moved the openRate and clickRate methods into this trait. Previously
they lived in the SentMailBuilder class. Either of those can be a good place. Here are my
thoughts on this decision:
277 / 327
Martin Joo - Domain-Driven Design with Laravel
Technically they belong to the SentMailBuilder because they query data from the
sent_mails table.
But conceptually, the HasPerformance trait is a better place for them, in my opinion.
This trait will be used in Broadcast , SequenceMail , and the Sequence model. We
can get the open rate by writing $broadcast->openRate() . So conceptually, I mean:
Broadcast is the one that has an open rate, not the SentMail model.
By creating this trait, we have a perfect place for these methods. The trait also defines a
sent_mails method. By adding this one, we have a more strict contract. And finally, it
defines the performance method, which the individual models implement:
278 / 327
Martin Joo - Domain-Driven Design with Laravel
279 / 327
Martin Joo - Domain-Driven Design with Laravel
click_rate: $this!%clickRate($total),
);
}
}
There are some duplications between these methods. We can remove this by using a
template method; it would look something like this:
trait HasPerformance
{
abstract public function totalInstances(): int;
And now, the models only need to override the totalInstances method:
280 / 327
Martin Joo - Domain-Driven Design with Laravel
It's a cool technique to remove these kinds of duplications where the difference is only a
few lines of code. However, the main disadvantage is:
Earlier, when I looked at the model's performance method, I knew exactly what it did.
Now I only see a weird one-liner that returns some count.
Even worse, in the case of Broadcast and SequenceMail , the total is the count of
sent_mail records, but when it comes to Sequences , the same variable holds the
number of active subscribers (completed or in progress). So I had to come up with
some general names such as totalInstances . Which I don't like to be honest.
I've decided not to refactor the original performance method for these reasons. My
general opinion is: sometimes it's okay to have three lines of duplicated code. The moment
it causes some trouble, I start to refactor it.
As the last step, we can eliminate the GetPerformanceAction and replace it with $model-
>performance calls. For example, in the GetBroadcastsViewModel :
281 / 327
Martin Joo - Domain-Driven Design with Laravel
282 / 327
Martin Joo - Domain-Driven Design with Laravel
Progress Of A Sequence
The last exciting functionality of sequences is their progress. I'm talking about the three
numbers in the boxes:
Total Subscribers: every subscriber who got at least one mail from the sequence. We
can call them 'active subscribers'.
In Progress: the subscriber's status is in progress, meaning they will receive more mail
from the sequence.
Completed: the subscriber's status is completed, meaning they won't receive any more
mail from the sequence.
Since we store these statuses in the sequence_subscriber table, it's very easy to query
them:
namespace Domain\Mail\Builders\Sequence;
use Domain\Mail\Enums\Sequence\SubscriberStatus;
use Illuminate\Database\Eloquent\Builder;
283 / 327
Martin Joo - Domain-Driven Design with Laravel
They go into the SequenceBuilder class. We also need a new DTO to store and return
these stats:
284 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Mail\DataTransferObjects\Sequence;
use Spatie\LaravelData\Data;
And finally, a view model that uses the DTO. This view model also contains:
285 / 327
Martin Joo - Domain-Driven Design with Laravel
!/
* @return Collection<int, TrackingData>
!"
public function mailPerformances(): Collection
{
return $this!%sequence!%mails
!%mapWithKeys(fn (SequenceMail $mail) !( [
$mail!%id !( $mail!%performance()
]);
}
286 / 327
Martin Joo - Domain-Driven Design with Laravel
287 / 327
Martin Joo - Domain-Driven Design with Laravel
The four boxes in the first row show how many people subscribed today, this week, this
month, or in total. This is a perfect example of some unstructured data that can be
improved using a DTO:
288 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\DataTransferObjects;
use Spatie\LaravelData\Data;
289 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Shared\ViewModels;
As you can see, this class is in the shared domain. There are several reasons:
It does not belong to any other domain. This class will use subscriber-related classes
and queries from the sent_mail table.
This single feature (the dashboard itself) is not big enough to create a separate domain.
Now let's discuss how we're approaching the date filters. One option is this:
290 / 327
Martin Joo - Domain-Driven Design with Laravel
It's pretty nice and readable, and generally speaking, there's nothing wrong with it.
However, in projects like this (where there are several other opportunities for more exotic
reports), I often find myself writing the same Carbon logic over and over again. And almost
certainly, the PM will ask me: "Can we change the 'This week' report for 'Past seven days'
instead?". "Yes, we can." I answer and immediately see myself using the Cmd + Shift +
F feature of PhpStorm and going through the 39 occurrences of "startOfWeek." (Except
when we have a new junior dev. Then he can practice using PhpStorm a little bit ! )
To avoid these kinds of problems, we can use a very simple DateFilter class:
291 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Shared\Filters;
use Carbon\Carbon;
class DateFilter
{
public function !&construct(
public readonly Carbon $startDate,
public readonly Carbon $endDate
) {}
292 / 327
Martin Joo - Domain-Driven Design with Laravel
There's nothing complicated going on here. It has some useful factory functions that we
can use like this:
293 / 327
Martin Joo - Domain-Driven Design with Laravel
And, of course, we can make it a bit more clean by using a scope in the
SubscriberBuilder class:
294 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Subscriber\Builders;
use Domain\Shared\Filters\DateFilter;
use Illuminate\Database\Eloquent\Builder;
Now the view model looks very clean, in my opinion, and we have a simple, but reusable
DateFilter :
295 / 327
Martin Joo - Domain-Driven Design with Laravel
this_month: Subscriber!$whereSubscribedBetween(
DateFilter!$thisMonth()
)!%count(),
this_week: Subscriber!$whereSubscribedBetween(
DateFilter!$thisWeek()
)!%count(),
today: Subscriber!$whereSubscribedBetween(
DateFilter!$today()
)!%count(),
);
}
}
296 / 327
Martin Joo - Domain-Driven Design with Laravel
All-Time Performance
The three boxes in the second row show you the all-time performance of your e-mails. We
already used reports like this for a particular model, such as a broadcast or a sequence
mail. Calculating the average open rate for every mail is no more complicated than
calculating it for a specific broadcast. In fact, it's much easier. But there's a significant
difference:
For that reason, there's no need to extract these queries. They can be some simple
functions in the view model:
297 / 327
Martin Joo - Domain-Driven Design with Laravel
Of course, we can use the same classes as before, the PerformanceData DTO and the
Percent value object.
298 / 327
Martin Joo - Domain-Driven Design with Laravel
Subscribers
Daily subscribers
For this feature, the dashboard needs to show a list grouped by day:
This means that on March 12, 2022, the user had six new subscribers and so on.
/**
* @return Collection<DailySubscribersData>
!"
public function dailySubscribers(): Collection
{
return DB!$table('subscribers')
!%select(
DB!$raw("
count(*) count,
date_format(subscribed_at, '%Y-%m-%d') day
")
)
!%groupBy('day')
!%orderByDesc('day')
299 / 327
Martin Joo - Domain-Driven Design with Laravel
!%whereUserId($this!%user!%id)
!%get()
!%map(fn (object $data) !(
DailySubscribersData!$from((array) $data)
);
}
count
day
The query will return the count by day because of the group by statement. Since this is
not an Eloquent query but a classic DB::table one, we have to filter by user_id . The
HasUser trait, which is being used in the Subscriber model, has no effect here. Because
it's a classic database query, it returns a collection of stdClass . This is why the object
type-hint is being used in the map function. At this point, you may recognize that I'm not a
big fan of arrays or standard objects, so let's map them into DTOs:
namespace Domain\Subscriber\DataTransferObjects;
use Spatie\LaravelData\Data;
300 / 327
Martin Joo - Domain-Driven Design with Laravel
[
[
'day' !( '2022-03-12',
'count' !( 6
],
];
301 / 327
Martin Joo - Domain-Driven Design with Laravel
The last and most simple report is the most recent subscribers. It's just a list of subscribers
ordered by their subscription date:
/**
* @return Collection<SubscriberData>
!"
public function recentSubscribers(): Collection
{
return Subscriber!$with(['form', 'tags'])
!%orderByDesc('subscribed_at')
!%take(10)
!%get()
!%map
!%getData();
}
302 / 327
Martin Joo - Domain-Driven Design with Laravel
Automations
The last feature I want to show you is automation. This is going to be one of the most fun
features. At its core, automation is something like this:
A landing page where people can submit their e-mail addresses to get informed about
the progress and get a discount.
An e-mail sequence of 8 or so e-mails about DDD concepts.
When someone submitted their e-mail address on the landing page, I added them to my e-
mail sequence. This was done by automation in ConvertKit.
Both events and actions are automation steps, and for now, every automation has only one
event. This event triggers the whole automation.
303 / 327
Martin Joo - Domain-Driven Design with Laravel
Upserting automations
We won't spend much time creating and updating automations because triggering them is a
more existing task. But I wanted to show you an example of how you can handle situations
when the request has a different shape than the database and the models.
DTO
{
"name": "Waiting List !% Sequence",
"steps": {
"event": {
"name": "subscribedToForm",
"value": 1
},
"actions": [
{
"name": "addToSequence",
304 / 327
Martin Joo - Domain-Driven Design with Laravel
"value": 19
}
]
}
}
It's quite good, but the steps key is unnecessary and a bit confusing. It's called steps ,
which is plural, but it's an object instead of an array. Let's assume for a minute that we
don't have control over this structure; we need to accept and process it.
namespace Domain\Automation\DataTransferObjects;
As you can see, there are no steps here, only event and actions . This is great but
won't work out of the box because laravel-data tries to map the whole request to this DTO.
And since they are different, it won't work.
305 / 327
Martin Joo - Domain-Driven Design with Laravel
By overriding the fromRequest , we can write our own mapping logic, but we can still use
the request's structure for validation:
We need to make one more small change. When laravel-data tries to validate a request, it
will use some automatic rules based on the shape of your DTO. For example, looking at
these properties:
306 / 327
Martin Joo - Domain-Driven Design with Laravel
id is optional.
name is a string and is required.
event and action are also required.
But it's not true. event and actions are not required in the request. Fortunately, it's easy
to override this default assumption:
Here we instruct laravel-data to use the array defined in rules no matter what properties
the class has. Now it works as expected. It will:
Upsert action
Now you can take a look at the action that creates or updates automations:
307 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Automation\Actions;
class UpsertAutomationAction
{
public static function execute(
AutomationData $data,
User $user
): Automation {
return DB!$transaction(function () use ($data, $user) {
$automation = self!$upsertAutomation($data, $user);
$automation!%steps!%each!%delete();
self!$upsertEvent($automation, $data);
self!$upsertActions($automation, $data);
return $automation!%load('steps');
});
}
}
Upserting automation:
308 / 327
Martin Joo - Domain-Driven Design with Laravel
At this point, I guess it's familiar. After that, the action will delete every step that the
automation has right now:
$automation!%steps!%each!%delete();
It only has an effect when the user is updating existing automation. After the steps are
deleted, we can save the event:
309 / 327
Martin Joo - Domain-Driven Design with Laravel
For now, I've decided to use a JSON column for the value, and it always has this shape:
{
"id": 1
}
Right now, every automation step will have one "target" or one value and is always an ID.
For example:
310 / 327
Martin Joo - Domain-Driven Design with Laravel
And basically, that's the whole action. Now we can move on to the exciting part. How can
we trigger and run these automations?
311 / 327
Martin Joo - Domain-Driven Design with Laravel
Running automations
We store the event and actions in the automation_steps table, which looks like this:
Now let's think about when automations will be triggered? For now, the only event is when
a subscriber subscribes to a form. Put in different words: a new subscriber is created and
has a form_id . The UpsertSubscriberAction seems like an excellent place to start.
So the plan is to dispatch an event after the subscriber has been created. There are going
to be a lot of classes, so here's the whole flow in a hand-crafted diagram:
312 / 327
Martin Joo - Domain-Driven Design with Laravel
313 / 327
Martin Joo - Domain-Driven Design with Laravel
class UpsertSubscriberAction
{
public static function execute(
SubscriberData $data,
User $user
): Subscriber {
$subscriber = Subscriber!$updateOrCreate(
[
'id' !( $data!%id,
],
[
!!*$data!%all(),
'form_id' !( $data!%form?!%id,
'user_id' !( $user!%id,
],
);
$subscriber!%tags()!%sync(
$data!%tags!%toCollection()!%pluck('id')
);
314 / 327
Martin Joo - Domain-Driven Design with Laravel
After creating the subscriber, it checks if $data has an ID and a form_id . This makes
sure that we only dispatch the event if:
Just as a side note, if you have more complicated if statements, it might be a good idea
to extract them out to the SubscriberData class, for example:
!) Usage:
class UpsertSubscriberAction
{
public static function execute(SubscriberData $data)
{
$data!%whenSubscribedViaForm(fn () !(
event(new SubscribedToFormEvent($subscriber, $user));
);
315 / 327
Martin Joo - Domain-Driven Design with Laravel
}
}
It does not bring much benefit to this use case, but it's a very cool technique for these
reasons:
All right, back to automations. So when creating a new subscriber, the action will dispatch a
SubscribedToFormEvent event:
namespace Domain\Automation\Events;
use Domain\Shared\Models\User;
use Domain\Subscriber\Models\Subscriber;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscribedToFormEvent
{
use Dispatchable, SerializesModels;
316 / 327
Martin Joo - Domain-Driven Design with Laravel
It's your regular "placeholder" class for an event. It's worth noting that it lives inside the
automation domain. After that, we need to list it in the EventServiceProvider :
namespace App\Providers;
use Domain\Automation\Events\SubscribedToFormEvent;
use Domain\Automation\Listeners\SubscribedToFormListener;
namespace Domain\Automation\Listeners;
use Domain\Automation\Events\SubscribedToFormEvent;
use Domain\Automation\Jobs\RunAutomationsJob;
class SubscribedToFormListener
{
public function handle(SubscribedToFormEvent $event)
{
RunAutomationsJob!$dispatch(
$event!%subscriber,
317 / 327
Martin Joo - Domain-Driven Design with Laravel
$event!%user,
Events!$SubscribedToForm,
);
}
}
One important thing: when the app handles a SubscribedToFormEvent it's very important
to run only automations with this particular type (yeah, right now, it's the only one, but we
can have more use-cases in the future). This is why the listener passes an
Events::SubscribedToForm value to the job.
I created a job for this because I want it to run in the background. There's no need to block
the current request. The job also passes the event type to the action:
318 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Automation\Jobs;
319 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Automation\Actions;
class RunAutomationsAction
{
public static function execute(
Subscriber $subscriber,
User $user,
Events $event
): void {
$automations = Automation!$with('steps')
!%whereBelongsTo($user)
!%whereHas('steps', function ($steps) use ($subscriber,
$event) {
$steps
!%whereType(AutomationStepType!$Event)
!%whereName($event)
!%where('value!%id', $subscriber!%form_id);
})
!%get();
320 / 327
Martin Joo - Domain-Driven Design with Laravel
}
}
The first part of this function queries every automation where there's a subscribedToForm
event with the given form_id . This line will check a value inside a JSON column:
!%where('value!%id', $subscriber!%form_id);
{
"id": 1
}
The second part of the function queries every action for the automation and runs them one
by one. Let's take a closer look at what's happening here:
The Actions enum is a list of all possible actions with a factory function:
321 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Automation\Enums;
use Domain\Automation\Actions\Steps\AddTagAction;
use Domain\Automation\Actions\Steps\AddToSequenceAction;
As I said earlier, every automation event has an event class, and every automation action
has an action class. This factory function will create a new instance from these action
classes. I'll show you these classes in a minute. Now let's go back to the
RunAutomationsAction :
$action = Actions!$from($step!%name)!%createAction();
$action($subscriber, $step);
After the action is created, it is being called. To call an object this way, it has to be
invokable. If you haven't seen this before, it's just a PHP magic function that looks like this:
322 / 327
Martin Joo - Domain-Driven Design with Laravel
class AddTagAction
{
public function !&invoke(
Subscriber $subscriber,
AutomationStep $step
) {
echo 'The action is called';
}
}
So this class doesn't need a function. The class itself acts as a function and can be called.
Of course, you can simply use a non-static execute method instead:
$action = Actions!$from($step!%name)!%createAction();
$action!%execute($subscriber, $step);
There's no real difference between the two. It's important to note that a static function
would be a poor choice in this case because it cannot be called dynamically. Or I guess
there's a way to do this, but it must be very low-level and technical.
So this is how we can instantiate and run the automation actions on the fly. Now let's see
the actions:
323 / 327
Martin Joo - Domain-Driven Design with Laravel
namespace Domain\Automation\Actions\Steps;
use Domain\Automation\Models\AutomationStep;
use Domain\Subscriber\Models\Subscriber;
class AddTagAction
{
public function !&invoke(
Subscriber $subscriber,
AutomationStep $step
): void {
$subscriber!%tags()!%attach($step!%value['id']);
}
}
class AddToSequenceAction
{
public function !&invoke(
Subscriber $subscriber,
AutomationStep $step
): void {
$subscriber!%sequences()!%attach($step!%value['id']);
}
}
It couldn't be easier. At the end of the day, running automations require some classes like
events, listeners, and jobs, but it was really simple at its core.
324 / 327
Martin Joo - Domain-Driven Design with Laravel
Conclusion
I know that it was a lot to take in, so I recommend you to take a break and come back once
again to check out the main ideas in the book. In my opinion, these are the most important
takeaways:
Using actions is a great way to write reusable classes for a single use case. Each action
takes care of one user story.
Using Data Transfer Objects can help you eliminate random, unstructured arrays. No
more functions with an array $data argument.
Writing custom query builders help you to get rid of models with 1000 lines of code. In
my experience, a model can grow 200+ lines without having a single query in it. That's
more than enough for one class.
Use value objects whenever possible. Some typical examples would be:
Percent
Money
E-mail address
Address
Financial metrics
Unit of measure
You have a lot of classes. Just consider a simple feature such as creating and updating
a subscriber. We wrote the following classes:
SubscriberData
FormData
TagData
UpsertSubscriberAction
SubscriberController
UpsertSubscriberViewModel
325 / 327
Martin Joo - Domain-Driven Design with Laravel
You can see there are a lot of classes compared to having a model and a controller.
After a while, it can be pretty annoying to maintain this many classes. However, I cannot
name a real disadvantage outside of being annoying. In general, I prefer having 50 small
classes over ten huge ones.
Using actions and view models makes it easy to duplicate code. You have smaller
queries all over the place, so it's harder to notice if something is duplicated. However, if
you write your queries inside models and have 1000 lines of code in every model, is it
better to avoid duplication? I know you have reusable methods, but can you spot the 5-
liner you need in the given situation? Or do you end up rewriting it with a different
name? Avoiding code duplication in a large project is hard, no matter what.
326 / 327
Martin Joo - Domain-Driven Design with Laravel
Thank You
Thank you for reading this book! I hope you got a lot of value from it and learned a few
things about domain-driven design and Laravel.
My name is Martin Joo, and I published several other books about Laravel:
I'm also publishing blog articles and tweets regularly. You can find every work of mine on
Twitter.
327 / 327