Table of Contents
When integrating with third-party APIs, manually writing DTOs (Data Transfer Objects) for every response structure is tedious and error-prone. Here’s a reusable Laravel command pattern that auto-generates JMS Serializer DTOs from captured API responses.
The Problem
You’re building an integration with an external API that returns complex nested JSON. Writing DTOs by hand means:
- Manually mapping every field
- Maintaining JMS annotations
- Keeping DTOs in sync when the API changes
The Solution: DTO Generator + Fixture Middleware
Step 1: Capture API Responses as Fixtures
Create a Guzzle middleware that saves raw API responses to fixture files during development:
// app/Http/Middleware/FixtureDumperMiddleware.php
namespace App\Http\Middleware;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class FixtureDumperMiddleware
{
public static function create(string $fixtureDir): callable
{
return Middleware::tap(
null,
function (RequestInterface $request, $options, ResponseInterface $response) use ($fixtureDir) {
$uri = $request->getUri()->getPath();
$filename = $fixtureDir . '/' . str_replace('/', '_', trim($uri, '/')) . '.json';
file_put_contents($filename, $response->getBody());
}
);
}
}
Wire it into your HTTP client:
// app/Providers/ApiServiceProvider.php
use GuzzleHttp\HandlerStack;
use App\Http\Middleware\FixtureDumperMiddleware;
public function register()
{
$this->app->singleton(PaymentClient::class, function ($app) {
$stack = HandlerStack::create();
if (app()->environment('local')) {
$stack->push(FixtureDumperMiddleware::create(storage_path('api_fixtures')));
}
return new PaymentClient([
'handler' => $stack,
'base_uri' => config('services.payment.base_url'),
]);
});
}
Step 2: Generate DTOs from Fixtures
Create an artisan command that reads fixture JSON and outputs PHP DTOs:
// app/Console/Commands/GenerateJmsDtosCommand.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class GenerateJmsDtosCommand extends Command
{
protected $signature = 'dto:generate {fixture} {--namespace=App\\DTO}';
protected $description = 'Generate JMS DTOs from API response fixture';
public function handle()
{
$fixturePath = storage_path('api_fixtures/' . $this->argument('fixture'));
$json = json_decode(file_get_contents($fixturePath), true);
$className = ucfirst(camel_case(basename($fixturePath, '.json')));
$namespace = $this->option('namespace');
$dto = $this->generateDto($className, $json, $namespace);
$outputPath = app_path('DTO/' . $className . '.php');
file_put_contents($outputPath, $dto);
$this->info("Generated: {$outputPath}");
}
protected function generateDto(string $className, array $data, string $namespace): string
{
$properties = [];
foreach ($data as $key => $value) {
$type = $this->inferType($value);
$properties[] = sprintf(
" /**\n * @JMS\Type(\"%s\")\n * @JMS\SerializedName(\"%s\")\n */\n public %s $%s;",
$type,
$key,
$this->phpType($type),
camel_case($key)
);
}
return sprintf(
"inferType($value[0]) . '>' : 'array';
}
return match (gettype($value)) {
'integer' => 'int',
'double' => 'float',
'boolean' => 'bool',
default => 'string',
};
}
protected function phpType(string $jmsType): string
{
if (str_starts_with($jmsType, 'array')) {
return 'array';
}
return $jmsType;
}
}
Usage
# 1. Make API calls in local environment (fixtures auto-saved)
php artisan tinker
>>> app(PaymentClient::class)->getTransaction('12345');
# 2. Generate DTO from captured fixture
php artisan dto:generate transaction_response.json --namespace=App\\DTO\\Payment
# Output: app/DTO/Payment/TransactionResponse.php
Benefits
- Speed: Generate 50+ DTOs in seconds instead of hours
- Accuracy: No typos or missed fields
- Maintenance: Re-run when API changes to update DTOs
- Reusable: Works with any JSON API
Real-World Impact
This pattern was used to generate 40+ DTOs for a payment gateway integration, reducing what would have been 2-3 days of manual work to 15 minutes of automated generation.
The generated DTOs work seamlessly with JMS Serializer for automatic JSON deserialization:
$response = $client->get('/api/transaction/12345');
$transaction = $serializer->deserialize(
$response->getBody(),
TransactionResponse::class,
'json'
);
Keep the generator command in your codebase as a reusable tool for future integrations.
Leave a Reply