Data Transfer Objects (DTOs) in PHP

Data Transfer Objects (DTOs) in PHP

DTOs allows you to pass data between classes. This is especially useful when interacting with 3rd party APIs

Lets say, we have a news website and we want to display news which are nearby to the user. First we need to get the user’s location via an API and then pass it to the database query. We can use the API from https://ip-api.com

class IpApi{
    public function getLocation(): array{
        // Make an API call
        // Sample api Response from https://ip-api.com
        return [
            'countryCode' => 'US',
            'country' => 'United States',
            'city' => 'New York'
        ];
    }
}

Now we have the API setup to fetch the user’s location via their IP address. Let’s now fetch news articles in user’s location.

use App\Models\News;

class NewsService{
    public function getNewsNearby(protected array $location){
        return News::where('country', $location['country'])->where('city', $location['city'])->get();
    }
}

// Get the location
$locationApi = new IpApi;
$location = $locationApi->getLocation();

// Fetch the news articles
$newsService = new NewsService;
$news = $newsService->getNewsNearby($location);

We should get the news articles in the user’s city. Everything works as expected.

Though what will happen if we change the API service in the future? Lets use https://ipgeolocation.io as an example.

class IpGeolocationApi{
    public function getLocation(): array{
        // Make an API call
        // Sample api Response from https://ipgeolocation.io
        return [
            'country_code2' => 'US',
            'country_code3' => 'USA',
            'country_name' => 'United States',
            'city' => 'New York'
        ];
    }
}

As you can see, the API response from ipgeolocation.io is a bit different and hence our previous code to fetch news will fail.

// This will now throw an error

use App\Models\News;

class NewsService{
    public function getNewsNearby(protected array $location){
        return News::where('country', $location['country'])->where('city', $location['city'])->get();
    }
}

One simple solution is to change $location['country'] to $location['country_name'] and the code should work. But we will have the same issue whenever we change the API in the future. Also it might not be feasible to change the code if we are using it multiple places.

Enter DTOs

What if we can have our own data structure independent of APIs? Using DTOs, we can simply create a PHP class to hold user’s location.

class LocationDTO{
    public function __construct(public readonly string $country, public readonly string $city){
    }
}

Now we can use this class instead of raw API responses in array. We can also type hint fields like $country and $city so we always have the correct data format.

Additionally, readonly properties ensures that the property values cannot be changed once initialized. This solidifies that we will always have unaltered and accurate data. Readonly properties are supported in PHP 8.1 and later.

Now lets refactor our code a bit and implement DTOs. We need to do couple of things:

  1. Replace array with a DTO in api response when fetching location.

  2. Replace array with a DTO when using location data in our application, like when fetching news articles.

// Replace array with a DTO
class IpApi{
    public function getLocation(): LocationDTO{
        // Make an API call
        // Sample api Response from https://ip-api.com
        $location = [
            'countryCode' => 'US',
            'country' => 'United States',
            'city' => 'New York'
        ];

        return new LocationDTO($location['country'], $location['city']);
    }
}

class IpGeolocationApi{
    public function getLocation(): LocationDTO{
        // Make an API call
        // Sample api Response from https://ipgeolocation.io
        $location = [
            'country_code2' => 'US',
            'country_code3' => 'USA',
            'country_name' => 'United States',
            'city' => 'New York'
        ];

        return new LocationDTO($location['country_name'], $location['city']);
    }
}

Now we can return a DTO from the API response instead of an array. Let’s use it in our application to fetch news articles nearby to the user.

use App\Models\News;

class NewsService{
    public function getNewsNearby(protected LocationDTO $location){
        return News::where('country', $location->country)->where('city', $location->city)->get();
    }
}

// Get the location
$locationApi = new IpApi;
$location  = $locationApi->getLocation();

// Fetch the news articles
$newsService = new NewsService;
$news = $newsService->getNewsNearby($location);

Now if we change our API in the future, we just have to switch our API class and the data format will remain the same.

// Get the location using https://ipgeolocation.io
$locationApi = new IpGelocationApi;
$location  = $locationApi->getLocation();

// Fetch the news articles
$newsService = new NewsService;
$news = $newsService->getNewsNearby($location);

Note that we didn’t had to change our logic to fetch news from the database as it uses DTO which does not change. Similarly wherever we use DTO in our codebase, we won’t have to make a change which makes maintenance easier.

Bonus: Use Interface

In modern frameworks like Laravel, you can bind an interface to the concrete class. So instead of manuallly initializing an API like new IpApi, we can use an interface.

interface LocationInterface{
    public function getLocation(): LocationDTO;
}

$this->app->bind(LocationInterface::class, IpApi::class);

Now you can fetch location in your code without using the IpApi class.

class NewsController{
    public function index(LocationInterface $location){
        $newsService = new NewsService;
        $news = $newsService->getNewsNearby($location);
    }
}

The benefit of this approach is you can change the API service in future with only one line of change.

//Use IpGelocationApi instead of IpApi
$this->app->bind(LocationInterface::class, IpGelocationApi::class);

Our controller will remain exactly as it is and it does not care which API service you are using.

This approach also satisfies 2 SOLID principles - Liskov Substitution Principle and Dependency Inversion Principle.

Hope this makes it easier to understand DTOs in PHP and how useful it can be. If you have any questions, I will be happy to answer them in the comments.