Mastering Data Transfer Objects in Laravel

March 25, 2024
Written by
Anumadu Udodiri Moses
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Mastering Data Transfer Objects in Laravel

Data Transfer Objects (DTOs) are a crucial component in modern software architecture, enhancing data handling and management across different layers of an application. In Laravel, DTOs offer a structured and efficient way to manage data transfer between the application's internal components and external interfaces. This tutorial will guide you through mastering DTOs in Laravel, from basic concepts to advanced implementations.

Prerequisites

The following technologies are required to follow along effectively

Understanding DTOs

A DTO is, essentially, a plain old PHP class that holds a bunch of data. It doesn't have any business logic. Its main purpose is to transfer data between layers of your application, such as from a controller to a service, or from a service to a view.

Benefits of using DTOs in web development

Below, I have outlined some of the more obvious reasons why you might want to use DTOs in development.

  • Encapsulation: Think of DTOs as boxes that connect related objects. Just as you can fit all your socks in one drawer, DTOs collect the same pieces of data. This makes data easier to handle and understand.

  • Type security: DTOs help ensure that the data being sent to your application is of the correct type. For example, when expecting a number, DTOs help ensure that you don’t accidentally get a text string. This keeps your data clean and reliable.

  • Reduced complexity: DTOs take a complex data structure and simplify it, making it easier to work with the data. In complex web development projects, sometimes data can feel like a tangled headset; DTOs help release them.

  • Improved performance: DTOs can speed up your application by ensuring that only important pieces of data are moved around. It’s like choosing to carry only what you need in your backpack rather than everything; It is very small and easy to handle.

Let's create a DTO class from scratch in Laravel without relying on external packages. This approach gives you more control and can be a great learning exercise.

Create a new Laravel project

Let's start by creating a Laravel project using Composer like so:

composer create-project laravel/laravel dto-project

After creating the project, navigate into the project directory using the command below.

cd dto-project

Set up the database

Change the database settings (DB_) in .env with your database details to work with any database of your choice; I used MySQL for this tutorial.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<<DB_NAME>>
DB_USERNAME=<<DB_USER_NAME>>
DB_PASSWORD=<<DB_PASSWORD>>

Generate database seed data

Our freshly created Laravel application is shipped with a User model, User migration, and seeder out of the proverbial box. Let's run our migration and seed the database to generate some test data for our DTO class. In database/seeder/DatabaseSeeder.php, replace the content with the code below to seed 10 users.

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        \App\Models\User::factory(10)->create();
    }
}

Then, run the command below to run the migration and generate seed data.

php artisan migrate:fresh --seed

Define a Dto class

Let's create a simple UserDTO class for a better understanding of DTOs. Inside your app directory, create a new directory named Dto and in that directory create a file named UserDTO.php file. Then, update the file with the following code.

<?php

namespace App\Dto;

class UserDto
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $password,
    ) {
    }

    public static function fromArray(array $data)
    {
        return new self(
            name: $data['name'],
            email: $data['email'],
            password: $data['password']
        );
    }
}

Let's take a closer look at the methods in the class above.

  • Properties: The public properties (name, email, and password) are the data we want to transfer. Making them public allows easy access. You could make them private/protected and use getters if encapsulation is a concern. Each of the data is of type string. This assignment ensures that only requests with the correct data type will be saved to the database 

  • Constructor: The __construct() method initializes the DTO with data. When you create an instance of UserDTO, you'll provide the values for name, email, and password.

  • A static creation method: The fromArray() static method is an optional convenience method that allows you to create an instance of the DTO from an associative array. This can be particularly useful when working with response data or array data fetched from a database. A good example of when to use this would be when working with the $request object in Laravel forms. 

Usage example

Let's put our UserDTO class to use in the app/Http/Controllers/UserController.php file. We will explore the two different ways that we can use our UserDTO class.

First, create the UserController.php file using the command below.

php artisan make:controller UserController

Then, head over to app/Http/Controllers/UserController.php and modify the contents with the code below.

<?php

namespace App\Http\Controllers;
use App\Models\User;
use App\Dto\UserDto;
use Illuminate\Support\Facades\Hash;

class UserController extends Controller
{
    protected static ?string $password;

    public function create()
    {
        $userDto = new UserDTO(fake()->name(), fake()->unique()->safeEmail(), static::$password ??= Hash::make('password'));

        $user = User::create([
            'name' => $userDto->name,
            'email' => $userDto->email,
            'password' => $userDto->password,
        ]);

        return response()->json($user);
    }
}

In the code above, we created a new user using the UserDTO class and some fake details using Faker. We instantiated a new UserDTO class and passed along the data needed to create a new user. It ensured the data passed along was in the correct type (strings in this case). We then passed the UserDTO class return value to the User::create() method to create a new user.

Alternatively, we can achieve the same result using the fromArray static method of the UserDTO class by the following.

<?php
namespace App\Http\Controllers;

use App\Models\User;
use App\Dto\UserDto;
use Illuminate\Support\Facades\Hash;

class UserController extends Controller
{
    protected static ?string $password;

    public function create()
    {
       $userDto = UserDto::fromArray([
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'password' => static::$password ??= Hash::make('password')
        ]);

        $user = User::create([
            'name' => $userDto->name,
            'email' => $userDto->email,
            'password' => $userDto->password,
        ]);
        return response()->json($user);
    }
}

To test this, add this route to your routes/web.php file like so.

Route::get('/test/dto', [\App\Http\Controllers\UserController::class, 'create']);

Now, you can test that it works using the route /test/dto in your browser of choice. The image below is the result of my test.

Browser screenshot

Use cases of DTOs in Laravel

There are different use cases for DTOs in Laravel development. You can use them in the following forms:

  • In API development for structuring responses

  • Form request validation and data transfer

  • Complex data structures for jobs or events

  • Service classes

Let's take a closer look at each of these use cases with examples.

API responses

One common use case for DTOs in Laravel is formatting API responses. This is particularly useful when you want to ensure a consistent structure across your API endpoints, or when you need to include additional computed properties that aren't directly stored in your database. 

The code below shows an example of this.

<?php

namespace App\DTO\Response;

class UserResponseDTO 
{
    public string $name;
    public string $email;
    public string $profile_picture;
}

Then, you can use this DTO in your controller to structure the API response as follows.

public function show(User $user) 
{
    $userResponseDTO = new UserResponseDTO([
        'name' => $user->name,
        'email' => $user->email,
        'profile_picture' => $user->profile_picture_url, // Assume this is a computed attribute
    ]);

    return response()->json($userResponseDTO);
}

Form requests validation and data transfer

DTOs can be particularly useful when working with form requests. You can use a DTO to encapsulate the data from a request, ensuring that only valid, sanitized data is passed through your application's layers.

First, validate your request as usual in a FormRequest class or directly in the controller, then instantiate a DTO with the validated data, as in the following example.

public function update(Request $request, User $user) 
{
    $validatedData = $request->validate([
        'name' => 'required|string',
        'email' => 'required|email',
    ]);

    $userDTO = new UserDTO($validatedData);

    // Pass DTO to a service or directly update the model
    $user->update($userDTO->toArray());
    return response()->json($user);
}

Complex data structures for jobs or events

DTOs shine when you need to work with complex data structures — especially when dispatching jobs or events that require specific data, as in the following example:

// Assuming you have a job that sends personalized emails to users
dispatch(new SendPersonalizedEmail($userDTO));

In the job's constructor, you can type-hint the DTO, ensuring that the job receives exactly what it needs, as in this example.

public function __construct(UserDTO $user) {
    $this->user = $user;
}

Service classes

When using service classes to handle business logic, DTOs can be used to transfer data from controllers to these services, ensuring that the services have all the data they need in a structured and type-safe manner.

class UserService 
{
    public function createUser(UserDTO $userDTO) {
        // Logic to create a user
    }
}

Then, in the controller:

public function store(Request $request) 
{
    $userDTO = new UserDTO($request->validated());
    app(UserService::class)->createUser($userDTO);
    // ...
}

How to use a DTO in a Laravel project

Let's take a look at a second practical example of using DTOs in a Laravel project. We created a Laravel project earlier. This example will walk you through a simple CRUD layer for a blog app, seeding data, and integrating DTOs into the blog controller. 

Create a migration and model for blog posts

To get started with the second DTO example implementation, navigate to the dto-project directory using the command below

cd dto-project

Then, run the following Artisan command to create a migration and a model for your blog posts:

php artisan make:model Post -m

Open the newly generated migration file. It's in the database/migrations directory and ends with _create_posts_table.php. Add fields for your blog posts by updating the up() function to match the definition below:

public function up()
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('content');
        $table->timestamps();
    });
}

We also need to make the model's database fields mass-assignable. Modify the content of the generated model file, app/Models/Post.php, updating it to match the following:

<?php

namespace App\Models;

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

class Post extends Model
{
    use HasFactory;
    protected $fillable = ['title', 'content'];
}

Then, run the migration:

php artisan migrate

Seed the database with data

The next thing to do is to create a Seeder for the Post model using the command below:

php artisan make:seeder PostsTableSeeder

Open the generated seeder file (database/seeders/PostsTableSeeder.php) and add some dummy data, by updating the run() function as follows:

public function run()
{
    \Illuminate\Support\Facades\DB::table('posts')->insert([
        [
            'title' => 'First Post',
            'content' => 'Content of the first post',
        ],
        [
            'title' => 'Second Post',
            'content' => 'Content of the second post',
        ],
    ]);
}

The next thing to do is to run the database migrations and then run our seeder:

php artisan migrate
php artisan db:seed --class=PostsTableSeeder

Create a DTO for blog posts

We created a Dto folder inside the app directory in the first example. Navigate to that directory and create a PostDTO.php file inside it. The file path should be app/Dto/PostDTO.php. Replace the content of the file with the following.

<?php

namespace App\Dto;

use Illuminate\Http\Request;

class PostDTO
{
    public function __construct(
        public readonly string $title,
        public readonly string $content,
    ) {
    }

    public static function fromArray(array $data)
    {
        return new self(
            title: $data['title'],
            content: $data['content'],
        );
    }
}

Create CRUD operations in the blog controller using the DTO

Let's create a controller for the blog posts using the command below.

php artisan make:controller PostController

Now, open app/Http/Controllers/PostController.php and use the PostDTO class in CRUD operations, by updating the controller to match the code below. For brevity, let's focus on the store() and update() methods as examples:

<?php

namespace App\Http\Controllers;

use App\DTO\PostDTO;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class PostController extends Controller
{
    public function store(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'title' => 'required|unique:posts|max:255',
            'content' => 'required',
        ]);
        if ($validator->fails()) {
            return response()->json(['message' => 'all fields are required']);
        }
        $postDTO = PostDTO::fromArray($validator->validated());
        $post = Post::create([
            'title' => $postDTO->title,
            'content' => $postDTO->content,
        ]);
        return response()->json($post);
    }

    public function update(Request $request, $id)
    {
        $validator = Validator::make($request->all(), [
            'title' => 'required|unique:posts|max:255',
            'content' => 'required',
        ]);
        if ($validator->fails()) {
            return response()->json(['message' => 'all fields are required']);
        }
        $postDTO = PostDTO::fromArray($validator->validated());
        $post = Post::findOrFail($id);
        $post->update([
            'title' => $postDTO->title,
            'content' => $postDTO->content,
        ]);
        return response()->json($post);
    }
}

We used our PostDTO class here to ensure data saved to the database are of the required type. We validated the data using the Validator::make() method and passed the validated data to the PostDTO class by calling the findOrFail() static method of the PostDTO.

Create a route

Our application needs a route. Let's create a simple API route to test. Open route/api.php and add the following to it.

Route::post('/create-post', [PostController::class, 'store']);
Route::post('/update-post', [PostController::class, 'update']);

Then, add the following use statement to the top of the file.

use App\Http\Controllers\PostController;

At the time of writing, the new Laravel version 11 does not come shipped with route/api.php. If you have Laravel 11,  you can install the API scaffolding with the following command:

php artisan install:api

Test the application

Now, we should be able to test the API we just created. For this tutorial, I will test using Postman and Ngrok.

The first step in our test would be to serve the application locally. Let's start our Laravel development server using the command below.

php artisan serve

This starts a server for development at http://127.0.0.1:8000. If port 8000 is in use, Laravel will choose a different port automatically. Our testing URL would be http://127.0.0.1:8000/api/create-post.

Open Postman and create a new request. Set the URL to http://127.0.0.1:8000/api/create-post and the request type to POST. Then, click Body > form-data. There, add two keys: title and content. Set title's value to "first post" and content's value to "first post body". Then, click Send.

The image below shows the result of a successful request.

Postman screenshot

Conclusion

DTOs offer a powerful paradigm for data handling in Laravel applications, from simplifying controller logic to enhancing API development. Following the practices outlined in this tutorial can improve your Laravel projects' structure, maintainability, and performance.

Happy coding!

Moses Anumadu is a software developer and online educator who loves to write clean, maintainable code. Offering content as a service. You can find him here.