2026-05-13
Arquitectura hexagonal con PHP paso a paso
Guia practica para crear una API en PHP con arquitectura hexagonal, Composer, Slim, PDO SQLite, PHPUnit y namespaces PSR-4.
Vamos a construir una API pequena en PHP usando arquitectura hexagonal. La idea no sera quedarnos en teoria, sino crear un proyecto que puedas copiar, pegar, ejecutar y probar.
El caso sera un invernadero automatizado. El sistema debe guardar temperatura y humedad, consultar el clima actual y decidir acciones basicas: mantener, ventilar, calentar o activar riego.
Usaremos PHP moderno, Composer, Slim como framework HTTP, PDO con SQLite para persistencia y PHPUnit para pruebas.
Lo que vas a construir
Al final tendras estos endpoints:
GET /weather
POST /weather
Y podras probarlos asi:
curl http://localhost:8000/weather
curl -X POST http://localhost:8000/weather \
-H "Content-Type: application/json" \
-d '{"temperature": 32, "humidity": 60}'
La respuesta del POST incluira acciones sugeridas por el dominio:
{
"temperature": 32,
"humidity": 60,
"actions": ["ventilate"]
}
Idea rapida de arquitectura hexagonal
La arquitectura hexagonal separa el sistema en tres partes:
- Dominio: reglas de negocio puras.
- Aplicacion: entrada al sistema, por ejemplo HTTP.
- Infraestructura: detalles tecnicos, por ejemplo SQLite.
La dependencia debe apuntar hacia el dominio:
HTTP -> Casos de uso -> Interfaces del dominio <- SQLite
El controlador no debe decidir si se riega. La base de datos no debe decidir si se ventila. Eso le toca al dominio.
Paso 1: crear el proyecto
Necesitas PHP 8.2 o superior y Composer instalado.
Crea la carpeta:
mkdir greenhouse-hexagonal-php
cd greenhouse-hexagonal-php
Inicializa Composer:
composer init --name="demo/greenhouse-hexagonal" --type=project --require="php:^8.2" --no-interaction
Instala paquetes conocidos del ecosistema PHP:
composer require slim/slim slim/psr7 vlucas/phpdotenv
composer require --dev phpunit/phpunit
Que hace cada paquete:
slim/slim: microframework HTTP para crear rutas.slim/psr7: implementacion PSR-7 para requests y responses.vlucas/phpdotenv: carga variables desde.env.phpunit/phpunit: pruebas automatizadas.
Paso 2: configurar namespaces con PSR-4
En PHP, los namespaces se gestionan muy bien con Composer. La regla sera:
namespace App\... -> carpeta src/
namespace Tests\... -> carpeta tests/
Abre composer.json y dejalo asi:
{
"name": "demo/greenhouse-hexagonal",
"type": "project",
"require": {
"php": "^8.2",
"slim/slim": "^4.0",
"slim/psr7": "^1.0",
"vlucas/phpdotenv": "^5.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"start": "php -S localhost:8000 -t public",
"test": "phpunit"
}
}
Luego regenera el autoload:
composer dump-autoload
Como leer un namespace:
namespace App\Domain\Greenhouse;
Composer buscara esa clase dentro de:
src/Domain/Greenhouse/
Por ejemplo:
App\Domain\Greenhouse\WeatherReading
debe vivir en:
src/Domain/Greenhouse/WeatherReading.php
Ese es el mapa mental mas importante.
Paso 3: crear carpetas
Crea esta estructura:
mkdir -p src/Domain/Greenhouse/Ports
mkdir -p src/Domain/Greenhouse/UseCases
mkdir -p src/Application/Http
mkdir -p src/Infrastructure/Persistence
mkdir -p public
mkdir -p storage
mkdir -p tests/Domain
Si estas en Windows PowerShell, usa esto:
New-Item -ItemType Directory -Force src\Domain\Greenhouse\Ports
New-Item -ItemType Directory -Force src\Domain\Greenhouse\UseCases
New-Item -ItemType Directory -Force src\Application\Http
New-Item -ItemType Directory -Force src\Infrastructure\Persistence
New-Item -ItemType Directory -Force public
New-Item -ItemType Directory -Force storage
New-Item -ItemType Directory -Force tests\Domain
La estructura quedara asi:
src/
Domain/
Greenhouse/
Ports/
UseCases/
Application/
Http/
Infrastructure/
Persistence/
public/
storage/
tests/
Paso 4: crear el dominio
El dominio sera la parte que no sabe nada de Slim, SQLite, JSON ni HTTP.
WeatherReading
Crea src/Domain/Greenhouse/WeatherReading.php:
<?php
declare(strict_types=1);
namespace App\Domain\Greenhouse;
use InvalidArgumentException;
final readonly class WeatherReading
{
public function __construct(
public float $temperature,
public float $humidity,
) {
if ($humidity < 0 || $humidity > 100) {
throw new InvalidArgumentException('La humedad debe estar entre 0 y 100.');
}
}
}
Esta clase representa una lectura del clima. Es un value object: solo existe para expresar un valor del dominio.
ClimateAction
Crea src/Domain/Greenhouse/ClimateAction.php:
<?php
declare(strict_types=1);
namespace App\Domain\Greenhouse;
enum ClimateAction: string
{
case Keep = 'keep';
case Heat = 'heat';
case Ventilate = 'ventilate';
case Irrigate = 'irrigate';
}
El enum evita strings sueltos por todo el sistema.
GreenhousePolicy
Crea src/Domain/Greenhouse/GreenhousePolicy.php:
<?php
declare(strict_types=1);
namespace App\Domain\Greenhouse;
final class GreenhousePolicy
{
/**
* @return list<ClimateAction>
*/
public function decide(WeatherReading $weather): array
{
$actions = [];
if ($weather->temperature < 18) {
$actions[] = ClimateAction::Heat;
}
if ($weather->temperature > 30 || $weather->humidity > 85) {
$actions[] = ClimateAction::Ventilate;
}
if ($weather->humidity < 45) {
$actions[] = ClimateAction::Irrigate;
}
return $actions ?: [ClimateAction::Keep];
}
}
Esta clase contiene la regla de negocio. Si manana cambian los rangos del invernadero, cambias esta clase y no el controlador.
Paso 5: crear el puerto de salida
El dominio necesita guardar y leer clima, pero no debe saber como. Para eso creamos una interfaz.
Crea src/Domain/Greenhouse/Ports/WeatherRepository.php:
<?php
declare(strict_types=1);
namespace App\Domain\Greenhouse\Ports;
use App\Domain\Greenhouse\WeatherReading;
interface WeatherRepository
{
public function save(WeatherReading $weather): void;
public function current(): ?WeatherReading;
}
Este archivo es clave. Es un puerto de salida. El dominio dice: "necesito algo que guarde y lea clima". No dice SQLite, MySQL ni PostgreSQL.
Paso 6: crear casos de uso
Los casos de uso son acciones concretas del sistema.
GetCurrentWeather
Crea src/Domain/Greenhouse/UseCases/GetCurrentWeather.php:
<?php
declare(strict_types=1);
namespace App\Domain\Greenhouse\UseCases;
use App\Domain\Greenhouse\Ports\WeatherRepository;
use App\Domain\Greenhouse\WeatherReading;
final readonly class GetCurrentWeather
{
public function __construct(
private WeatherRepository $repository,
) {}
public function __invoke(): WeatherReading
{
return $this->repository->current() ?? new WeatherReading(20, 50);
}
}
Si no existe clima guardado, devolvemos un clima inicial.
RegisterWeatherReading
Crea src/Domain/Greenhouse/UseCases/RegisterWeatherReading.php:
<?php
declare(strict_types=1);
namespace App\Domain\Greenhouse\UseCases;
use App\Domain\Greenhouse\GreenhousePolicy;
use App\Domain\Greenhouse\Ports\WeatherRepository;
use App\Domain\Greenhouse\WeatherReading;
final readonly class RegisterWeatherReading
{
public function __construct(
private WeatherRepository $repository,
private GreenhousePolicy $policy,
) {}
public function __invoke(WeatherReading $weather): array
{
$this->repository->save($weather);
return [
'weather' => $weather,
'actions' => $this->policy->decide($weather),
];
}
}
Este caso de uso guarda la lectura y devuelve las acciones sugeridas.
Paso 7: crear el adaptador SQLite
Ahora implementamos el puerto WeatherRepository usando PDO y SQLite.
Crea src/Infrastructure/Persistence/SqliteWeatherRepository.php:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence;
use App\Domain\Greenhouse\Ports\WeatherRepository;
use App\Domain\Greenhouse\WeatherReading;
use PDO;
final readonly class SqliteWeatherRepository implements WeatherRepository
{
public function __construct(
private PDO $pdo,
) {
$this->createTable();
}
public function save(WeatherReading $weather): void
{
$this->pdo->exec('DELETE FROM weather');
$statement = $this->pdo->prepare(
'INSERT INTO weather (temperature, humidity, recorded_at)
VALUES (:temperature, :humidity, :recorded_at)'
);
$statement->execute([
'temperature' => $weather->temperature,
'humidity' => $weather->humidity,
'recorded_at' => date(DATE_ATOM),
]);
}
public function current(): ?WeatherReading
{
$statement = $this->pdo->query(
'SELECT temperature, humidity
FROM weather
ORDER BY recorded_at DESC
LIMIT 1'
);
$row = $statement->fetch(PDO::FETCH_ASSOC);
if (! $row) {
return null;
}
return new WeatherReading(
(float) $row['temperature'],
(float) $row['humidity'],
);
}
private function createTable(): void
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS weather (
temperature REAL NOT NULL,
humidity REAL NOT NULL,
recorded_at TEXT NOT NULL
)'
);
}
}
Observa la direccion de dependencia:
SqliteWeatherRepository implements WeatherRepository
La infraestructura depende del dominio. El dominio no depende de la infraestructura.
Paso 8: crear el controlador HTTP
El controlador pertenece a la capa de aplicacion. Traduce HTTP a casos de uso.
Crea src/Application/Http/WeatherController.php:
<?php
declare(strict_types=1);
namespace App\Application\Http;
use App\Domain\Greenhouse\UseCases\GetCurrentWeather;
use App\Domain\Greenhouse\UseCases\RegisterWeatherReading;
use App\Domain\Greenhouse\WeatherReading;
use InvalidArgumentException;
final readonly class WeatherController
{
public function __construct(
private GetCurrentWeather $currentWeather,
private RegisterWeatherReading $registerWeather,
) {}
public function show(): array
{
$weather = ($this->currentWeather)();
return [
'temperature' => $weather->temperature,
'humidity' => $weather->humidity,
];
}
public function update(array $input): array
{
if (! isset($input['temperature'], $input['humidity'])) {
throw new InvalidArgumentException('temperature y humidity son requeridos.');
}
$result = ($this->registerWeather)(
new WeatherReading(
(float) $input['temperature'],
(float) $input['humidity'],
)
);
return [
'temperature' => $result['weather']->temperature,
'humidity' => $result['weather']->humidity,
'actions' => array_map(
fn ($action) => $action->value,
$result['actions'],
),
];
}
}
Este controlador no conoce SQL. Solo recibe datos, crea un objeto del dominio y llama al caso de uso.
Paso 9: crear la entrada HTTP con Slim
Crea public/index.php:
<?php
declare(strict_types=1);
use App\Application\Http\WeatherController;
use App\Domain\Greenhouse\GreenhousePolicy;
use App\Domain\Greenhouse\UseCases\GetCurrentWeather;
use App\Domain\Greenhouse\UseCases\RegisterWeatherReading;
use App\Infrastructure\Persistence\SqliteWeatherRepository;
use Dotenv\Dotenv;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
require __DIR__ . '/../vendor/autoload.php';
$root = dirname(__DIR__);
if (file_exists($root . '/.env')) {
Dotenv::createImmutable($root)->safeLoad();
}
$storagePath = $root . '/storage';
if (! is_dir($storagePath)) {
mkdir($storagePath, 0777, true);
}
$pdo = new PDO('sqlite:' . $storagePath . '/greenhouse.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$repository = new SqliteWeatherRepository($pdo);
$policy = new GreenhousePolicy();
$controller = new WeatherController(
new GetCurrentWeather($repository),
new RegisterWeatherReading($repository, $policy),
);
$app = AppFactory::create();
$app->addBodyParsingMiddleware();
$app->get('/weather', function (Request $request, Response $response) use ($controller): Response {
return jsonResponse($response, $controller->show());
});
$app->post('/weather', function (Request $request, Response $response) use ($controller): Response {
try {
$payload = (array) $request->getParsedBody();
return jsonResponse($response, $controller->update($payload));
} catch (Throwable $exception) {
return jsonResponse($response, [
'error' => $exception->getMessage(),
], 422);
}
});
$app->run();
function jsonResponse(Response $response, array $payload, int $status = 200): Response
{
$response->getBody()->write(json_encode($payload, JSON_PRETTY_PRINT));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($status);
}
Aqui conectamos todo:
Slim route -> WeatherController -> UseCase -> WeatherRepository interface -> SQLite adapter
Esto se llama composicion de dependencias. En una app Laravel, esta parte normalmente viviria en un service provider o en el container.
Paso 10: ejecutar la API
Primero aseguremos que Composer reconoce los namespaces:
composer dump-autoload
Levanta el servidor:
composer start
Prueba el clima actual:
curl http://localhost:8000/weather
Respuesta esperada:
{
"temperature": 20,
"humidity": 50
}
Actualiza el clima:
curl -X POST http://localhost:8000/weather \
-H "Content-Type: application/json" \
-d '{"temperature": 32, "humidity": 60}'
Respuesta esperada:
{
"temperature": 32,
"humidity": 60,
"actions": [
"ventilate"
]
}
Consulta nuevamente:
curl http://localhost:8000/weather
Ahora debe devolver:
{
"temperature": 32,
"humidity": 60
}
En Windows PowerShell, si curl se comporta raro, usa curl.exe.
Paso 11: configurar PHPUnit
Crea phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Application">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Paso 12: probar el dominio sin base de datos
Crea tests/Domain/RegisterWeatherReadingTest.php:
<?php
declare(strict_types=1);
namespace Tests\Domain;
use App\Domain\Greenhouse\ClimateAction;
use App\Domain\Greenhouse\GreenhousePolicy;
use App\Domain\Greenhouse\Ports\WeatherRepository;
use App\Domain\Greenhouse\UseCases\RegisterWeatherReading;
use App\Domain\Greenhouse\WeatherReading;
use PHPUnit\Framework\TestCase;
final class RegisterWeatherReadingTest extends TestCase
{
public function test_it_saves_weather_and_decides_to_ventilate(): void
{
$repository = new class implements WeatherRepository {
public ?WeatherReading $saved = null;
public function save(WeatherReading $weather): void
{
$this->saved = $weather;
}
public function current(): ?WeatherReading
{
return $this->saved;
}
};
$useCase = new RegisterWeatherReading(
$repository,
new GreenhousePolicy(),
);
$result = $useCase(new WeatherReading(32, 60));
$this->assertSame(32.0, $repository->saved->temperature);
$this->assertSame(60.0, $repository->saved->humidity);
$this->assertContains(ClimateAction::Ventilate, $result['actions']);
}
}
Ejecuta:
composer test
Resultado esperado:
OK (1 test, 3 assertions)
Esta prueba no usa Slim ni SQLite. Prueba el comportamiento del dominio con un repositorio falso. Eso es arquitectura hexagonal aplicada de verdad.
Paso 13: probar la politica del invernadero
Crea tests/Domain/GreenhousePolicyTest.php:
<?php
declare(strict_types=1);
namespace Tests\Domain;
use App\Domain\Greenhouse\ClimateAction;
use App\Domain\Greenhouse\GreenhousePolicy;
use App\Domain\Greenhouse\WeatherReading;
use PHPUnit\Framework\TestCase;
final class GreenhousePolicyTest extends TestCase
{
public function test_it_heats_when_temperature_is_low(): void
{
$policy = new GreenhousePolicy();
$actions = $policy->decide(new WeatherReading(12, 60));
$this->assertSame([ClimateAction::Heat], $actions);
}
public function test_it_irrigates_when_humidity_is_low(): void
{
$policy = new GreenhousePolicy();
$actions = $policy->decide(new WeatherReading(24, 30));
$this->assertSame([ClimateAction::Irrigate], $actions);
}
public function test_it_keeps_when_weather_is_stable(): void
{
$policy = new GreenhousePolicy();
$actions = $policy->decide(new WeatherReading(24, 60));
$this->assertSame([ClimateAction::Keep], $actions);
}
}
Ejecuta otra vez:
composer test
Ahora deberias tener cuatro pruebas.
Que parte es cada capa
Dominio:
src/Domain/Greenhouse/WeatherReading.php
src/Domain/Greenhouse/ClimateAction.php
src/Domain/Greenhouse/GreenhousePolicy.php
src/Domain/Greenhouse/Ports/WeatherRepository.php
src/Domain/Greenhouse/UseCases/GetCurrentWeather.php
src/Domain/Greenhouse/UseCases/RegisterWeatherReading.php
Aplicacion:
src/Application/Http/WeatherController.php
Infraestructura:
src/Infrastructure/Persistence/SqliteWeatherRepository.php
Entrada real:
public/index.php
Por que esto es hexagonal
El dominio no importa PDO, Slim, Request, Response ni nada de infraestructura.
El caso de uso depende de esta interfaz:
use App\Domain\Greenhouse\Ports\WeatherRepository;
Y SQLite implementa esa interfaz:
final readonly class SqliteWeatherRepository implements WeatherRepository
Por eso puedes cambiar SQLite por PostgreSQL creando otro adaptador:
PostgresWeatherRepository implements WeatherRepository
El controlador y el caso de uso no tendrian que cambiar.
Como seria en Laravel
Si usas Laravel, no necesitas Slim. Laravel seria tu adaptador de entrada HTTP.
La idea seria enlazar la interfaz con la implementacion en un service provider:
use App\Domain\Greenhouse\Ports\WeatherRepository;
use App\Infrastructure\Persistence\SqliteWeatherRepository;
$this->app->bind(
WeatherRepository::class,
SqliteWeatherRepository::class,
);
Luego inyectas el caso de uso en un controlador Laravel. Pero el dominio queda igual. Esa es la gracia: Laravel se adapta al dominio, no al reves.
Siguiente paso
Este proyecto solo controla clima. Para acercarlo mas a un invernadero real, los siguientes puertos podrian ser:
IrrigationDevice: encender o apagar riego.VentilationDevice: abrir o cerrar ventilacion.SensorReader: leer sensores externos.ClimateEventPublisher: publicar eventos a Redis, RabbitMQ o MQTT.
La estructura no cambia. Agregas interfaces en el dominio y adaptadores en infraestructura.
Esa es la parte potente de la arquitectura hexagonal: el negocio queda en el centro y el resto del mundo se conecta alrededor.