Applying Domain-Driven Design in PHP and Symfony: A Hands-On Guide

· Silas Joisten · 4 minutes to read
Domain Driven Design practical approach

Learn how to apply Domain-Driven Design (DDD) principles in Symfony with practical examples. Discover the power of value objects, repositories, and bounded contexts.

Introduction

Domain-Driven Design (DDD) helps developers structure their applications around real business logic while keeping concerns well-separated. In my previous post, I explained the theoretical foundations of DDD, but today we’ll dive into a real-world example by building a weather API client using OpenWeatherMap and Symfony.

What Will You Learn?

  • How to structure an API client using DDD principles

  • Using value objects to encapsulate request and response logic

  • Implementing a repository to handle API interactions

  • Keeping API logic separate from business logic

  • Using scoped HTTP clients in Symfony for better con

By the end of this post, you’ll be able to integrate third-party APIs into Symfony while maintaining a clean architecture.

Use Case: Fetching Weather Data from OpenWeatherMap

We will build a DDD-compliant API client that fetches current weather data from OpenWeatherMap.

API Details

  • Base URL:

    https://api.openweathermap.org/data/2.5/weather
  • Request Parameters:

    • q (City name, e.g., "Berlin")

    • units (Unit system: "metric",  "imperial""standard")

  • Example Request:

    GET https://api.openweathermap.org/data/2.5/weather?q=Berlin&units=metric&appid=YOUR_API_KEY
  • Example Response:

    {
      "coord": { "lon": 13.4105, "lat": 52.5244 },
      "weather": [{ "main": "Clouds", "description": "scattered clouds" }],
      "main": { "temp": 21.5, "pressure": 1012, "humidity": 60 },
      "wind": { "speed": 5.1, "deg": 220 },
      "name": "Berlin"
    }

Step 1: Defining the API Interface

To keep our API logic decoupled from the application, we define an interface describing how the API client should work.

// ...
namespace App\Weather\Api;

use App\Weather\Domain\WeatherRequest;
use App\Weather\Domain\WeatherResponse;
use App\Weather\Domain\Region;

interface WeatherApiInterface
{
    public function forRegion(Region $region, WeatherRequest $request = new WeatherRequest()): WeatherResponse;
}

Why Use an Interface?

  • Abstracts external dependencies (e.g., OpenWeatherMap)

  • Ensures flexibility (easy to switch API providers)

  • Allows dependency injection for testing and mock implementations

By following this principle, our application remains loosely coupled and easier to extend and test.

Step 2: Implementing Value Objects

Region (Value Object)

Encapsulates and validates city names.

// ...
use Webmozart\Assert\Assert;

final readonly class Region
{
    public function __construct(public string $value)
    {
        Assert::stringNotEmpty($value);
        Assert::notWhitespaceOnly($value);
    }
}

Unit (Enum Value Object)

Represents predefined unit values, ensuring strict type safety and extensibility.

enum Unit: string
{
    case METRIC = 'metric';
    case IMPERIAL = 'imperial';
    case STANDARD = 'standard';
}

WeatherRequest (Value Object)

Represents all possible filters or query options available for the API.

// ...
final readonly class WeatherRequest implements \JsonSerializable
{
    public function __construct(
        public Unit $unit = Unit::METRIC,
    ) {
    }

    public function jsonSerialize(): array
    {
        return [
            'unit' => $this->unit->value,
        ];
    }
}

WeatherResponse (Value Object)

Validates and structures weather data returned by the API.

use Webmozart\Assert\Assert;

final readonly class WeatherResponse
{
    public function __construct(array $values)
    {
        Assert::keyExists($values, 'name');
        $this->city = $values['name'];

        Assert::keyExists($values, 'main');
        Assert::keyExists($values['main'], 'temp');
        $this->temperature = $values['main']['temp'];

        Assert::keyExists($values, 'weather');
        Assert::isList($values['weather']);
        Assert::count($values['weather'], 1);
        Assert::keyExists($values['weather'][0], 'description');
        $this->description = $values['weather'][0]['description'];
        
        // of course, this is a very simplified example
    }
}

Why Use Value Objects?

  • Ensures strong data integrity

  • Encapsulates domain logic

  • Prevents invalid data from being passed around

  • Enhances testability and readability

Using enums like Unit also improves developer experience (DX) since it provides clear constraints and prevents incorrect values from being used.

Step 3: Implementing the Weather API Client

We now create a Symfony HttpClient-based implementation for our API client.

// ...
final class WeatherApi implements WeatherApiInterface
{
    public function __construct(
        private HttpClientInterface $client,
        #[\SensitiveParameter]
        #[Autowire(env: 'OPENWEATHER_API_KEY')]
        private string $apiKey
    ) {}

    public function forRegion(Region $region, WeatherRequest $request = new WeatherRequest()): WeatherResponse
    {
        $response = $this->client->request('GET', 'https://api.openweathermap.org/data/2.5/weather', [
            'query' => [...$request->jsonSerialize(), 'q' => $region->name, 'appid' => $this->apiKey],
        ]);

        return new WeatherResponse($response->toArray());
    }
}

Alternatively, instead of injecting the generic HTTP client, Symfony allows us to define a scoped HTTP client for OpenWeatherMap.

framework:
  http_client:
    scoped_clients:
      openweather.client:
        base_uri: 'https://api.openweathermap.org/data/2.5/weather'
        headers:
          Accept: 'application/json'
        query:
          appid: '%env(OPENWEATHER_API_KEY)%'

And now modify the in the previous step implemented Client

final class WeatherApi implements WeatherApiInterface
{
    public function __construct(
+        private HttpClientInterface $openweatherClient,
-        private HttpClientInterface $client,
-        #[\SensitiveParameter]
-        #[Autowire(env: 'OPENWEATHER_API_KEY')]
-        private string $apiKey
    ) {}

    public function forRegion(Region $region, WeatherRequest $request = new WeatherRequest()): WeatherResponse
    {
-        $response = $this->client->request('GET', 'https://api.openweathermap.org/data/2.5/weather', [
-            'query' => [...$request->jsonSerialize(), 'q' => $region->name, 'appid' => $this->apiKey],
+        $response = $this->openweatherClient->request('GET', 'https://api.openweathermap.org/data/2.5/weather', [
+            'query' => [...$request->jsonSerialize(), 'q' => $region->name],

        ]);

        return new WeatherResponse($response->toArray());
    }
}

Step 4: Understanding the Directory Structure

When working with DDD, keeping code organized is crucial. We use the Bridge pattern to separate the external API logic from our business logic.

src/
├── Bridge/
│   ├── WeatherApi/
│   │   ├── Domain/
│   │   │   ├── Region.php
│   │   │   ├── Unit.php
│   │   │   ├── WeatherRequest.php
│   │   │   ├── WeatherResponse.php
│   │   ├── WeatherApi.php
│   │   ├── WeatherApiInterface.php

Why This Structure?

  • Encapsulates API integration logic under Bridge\WeatherApi

  • Keeps domain logic independent of API details

  • Facilitates maintenance and testing by isolating external dependencies

Using this approach allows us to switch API providers or modify implementation details without affecting the core domain logic.

Conclusion

This example demonstrates how to integrate an external API in Symfony using DDD principles

  • Encapsulate API logic in a repository

  •  Use value objects for request and response validation

  • Abstract API interactions behind an interface

  •  Use Symfony's scoped HTTP clients for cleaner configuration (optional)

By structuring your Symfony API client this way, you ensure maintainability, decoupling, and testability.

Do you want to apply DDD in your Symfony projects?

At SensioLabs, our Symfony and PHP experts can help you structure your projects efficiently: intelligent code factoring, good development practices and scalable architecture.

This might also interest you

Fabien Potencier
Elise Hamimi

SymfonyCon Amsterdam 2025: Our Recap and the Highlights

After an iconic first edition in 2019, SymfonyCon made its big comeback to Amsterdam. From the start, you could feel the energy of a highly anticipated conference: more than 1,200 attendees, 39 nationalities, the biggest Symfony community reunion of the year, great discoveries... and a fun atmosphere. This year was extra special because it was the 20th anniversary of Symfony. SensioLabs was there: we'll tell you all about our experience there!

Read more
PHP 8.5 URI extension
Oskar Stark

PHP 8.5's New URI Extension: A Game-Changer for URL Parsing

PHP 8.5 introduces a powerful new URI extension that modernizes URL handling. With support for both RFC 3986 and WHATWG standards, the new Uri class provides immutable objects, fluent interfaces, and proper validation - addressing all the limitations of the legacy parse_url() function. This guide shows practical before/after examples and explains when to use each standard.

Read more
Open in new tab
Silas Joisten

The Tab Trap: Why Forcing New Tabs Is Bad UX

We’ve all done it — added target="_blank" to a link to “help users” stay on our site. But what feels like a harmless convenience often creates confusion, breaks accessibility, and introduces hidden security risks.

Read more
3 dog heads
Mathieu Santostefano

Bring Your Own HTTP client

Break free from rigid dependencies in your PHP SDKs. Learn how to use PSR-7, PSR-17, and PSR-18 standards along with php-http/discovery to allow users to bring their favorite HTTP client, whether it's Guzzle, Symfony HttpClient, or another. A must-read for PHP and Symfony developers.

Read more
Blue sign on a building with several Now What? letters
Thibaut Chieux

How To Prioritize Messages When Building Asynchronous Applications With Symfony Messenger

Asynchronous processing offers benefits like decoupled processes and faster response times, but managing message priorities can become a challenge. When dealing with tasks ranging from password resets to complex exports, ensuring timely delivery of critical messages is essential. This article explores common asynchronous processing issues and provides solutions using Symfony Messenger, allowing you to optimize your application without extensive refactoring.

Read more
Two images: on the left many cars stuck in a traffic jam with the sign "All directions" above, on the right a blue car moving forward alone on the highway with the sign "Service Subscriber" and a Symfony logo above
Steven Renaux

Symfony Lazy Services with Style: Boost DX using Service Subscribers

Boost your Symfony app's performance and developer experience! Learn how to use Service Subscribers and traits for lazy service loading to reduce eager instantiation, simplify dependencies, and create modular, maintainable code.

Read more
the surface of the earth seen from the space with city lights forming networks
Imen Ezzine

HTTP Verbs: Your Ultimate Guide

HTTP Verbs Explained: Learn the basics of GET, POST, PUT, DELETE, and more. This article explains how they work, their applications, and their security implications.

Read more
Toy factory production line
Silas Joisten

Supercharging Symfony Testing with Zenstruck Foundry

Zenstruck Foundry has revolutionized the way we write tests in Symfony. In this post, you’ll learn how expressive factories, isolated test data, and a smoother developer experience helped us streamline our testing workflow and boost productivity.

Read more
Image