Article
Laravel에서 Spring처럼 Modern한 DTO 구현하기
PHP 8 Attribute와 Modern한 Laravel 개발

PHP 8에서 Attribute 기능이 추가되면서 Laravel도 Java Spring처럼 우아한 코드 작성이 가능해졌습니다. 특히 DTO(Data Transfer Object) 패턴 구현에서 큰 변화를 가져왔습니다.
기존 Laravel DTO의 문제점
Request 클래스와 DTO의 이중 관리
전통적인 Laravel 개발에서는:
// routes/api.php
Route::post('/dogs', [DogController::class, 'store']);
// DogRequest.php
public function rules()
{
return [
'name' => 'required|string',
'type' => 'required|string|min:5',
];
}
// DogDTO.php
class DogDTO
{
public function __construct(
public string $name,
public string $type,
) {}
}
문제점:
- Route 정의는
routes/디렉토리 - Request 클래스는
Requests/디렉토리 - DTO 클래스는
DTO/디렉토리 - 유효성 검사 규칙이 분산되어 있음
코드를 찾아다니며 읽어야 하고, 데이터 구조가 어디에 정의되어 있는지 파악하기 어렵습니다.
해결책: Spatie Laravel Data
설치 방법
# laravel-data 패키지
composer require spatie/laravel-data
php artisan vendor:publish --provider="Spatie\LaravelData\LaravelDataServiceProvider" --tag="data-config"
# laravel-route-attributes 패키지
composer require spatie/laravel-route-attributes
php artisan vendor:publish --provider="Spatie\RouteAttributes\RouteAttributesServiceProvider" --tag="config"
기본 사용법
1단계: DTO 정의 (유효성 검사 포함)
<?php
namespace App\DTO;
use Spatie\LaravelData\Attributes\Validation as Rule;
use Spatie\LaravelData\Data;
class DogData extends Data
{
public function __construct(
#[Rule\Required]
public string $name,
#[Rule\Min(5)]
#[Rule\Nullable]
public ?string $type
) {}
}
장점:
- DTO 클래스 하나에 데이터 구조와 유효성 검사 규칙이 모두 포함
- IDE 자동완성 지원
- 타입 안정성
2단계: Controller에서 Route Attribute 사용
<?php
namespace App\Http\Controllers;
use App\DTO\DogData;
use Spatie\RouteAttributes\Attributes\{Get, Post, Patch, Delete, Prefix};
#[Prefix('dogs')]
class DogController extends Controller
{
#[Get('/', name: 'dog.index')]
public function index(): array
{
return [
'type' => 'dog',
'list' => [],
];
}
#[Get('/{id}', name: 'dog.show')]
public function show($id): array
{
return [
'type' => 'dog',
'id' => $id,
];
}
#[Post('/', name: 'dog.store')]
public function store(DogData $dto): array
{
return [
'type' => $dto->type,
'name' => $dto->name,
];
}
#[Patch('/{id}', name: 'dog.update')]
public function update($id, DogData $dto): array
{
return [
'id' => $id,
'type' => $dto->type,
'name' => $dto->name,
];
}
}
Modern한 DTO의 장점들
1. 선언형 Route 정의
Before (전통적인 방식)
// routes/api.php (어딘가에 흩어져 있음)
Route::get('dogs', [DogController::class, 'index']);
Route::get('dogs/{id}', [DogController::class, 'show']);
Route::post('dogs', [DogController::class, 'store']);
Route::patch('dogs/{id}', [DogController::class, 'update']);
After (Modern 방식)
// Controller 클래스에 모두 정의
#[Get('/', name: 'dog.index')]
public function index() { }
#[Get('/{id}', name: 'dog.show')]
public function show($id) { }
#[Post('/', name: 'dog.store')]
public function store(DogData $dto) { }
#[Patch('/{id}', name: 'dog.update')]
public function update($id, DogData $dto) { }
이점:
- Route와 메서드가 함께 있어 한눈에 이해
- 리팩토링이 쉬움
- 스프링의
@GetMapping같은 경험
2. DTO 중심의 유효성 검사
유효성 검사가 Request 클래스에서 DTO로 옮겨집니다.
class UserData extends Data
{
public function __construct(
#[Rule\Required]
#[Rule\Email]
public string $email,
#[Rule\Required]
#[Rule\Min(8)]
public string $password,
#[Rule\Required]
public string $name,
) {}
}
Controller는 매우 간단해집니다:
#[Post('/')]
public function register(UserData $user)
{
// $user는 이미 유효성 검사를 통과한 상태
User::create($user->toArray());
return ['success' => true];
}
3. 자동 타입 캐스팅
class UserData extends Data
{
public function __construct(
#[Rule\Required]
#[Rule\Numeric]
public int $age, // 문자열 "25" → 정수 25로 자동 변환
#[Rule\Required]
public bool $isActive, // "1" → true로 자동 변환
) {}
}
고급 기능
중첩 DTO
주소를 별도의 DTO로 분리:
class AddressData extends Data
{
public function __construct(
#[Rule\Required]
public string $street,
#[Rule\Required]
public string $city,
#[Rule\Required]
public string $zipCode,
) {}
}
class UserData extends Data
{
public function __construct(
#[Rule\Required]
public string $name,
#[Rule\Required]
public AddressData $address, // 중첩 DTO
) {}
}
요청 JSON:
{
"name": "John",
"address": {
"street": "123 Main St",
"city": "Seoul",
"zipCode": "12345"
}
}
커스텀 메서드
class UserData extends Data
{
public function __construct(
public string $firstName,
public string $lastName,
) {}
public function fullName(): string
{
return "{$this->firstName} {$this->lastName}";
}
}
Array 변환
$data = UserData::from([
'firstName' => 'John',
'lastName' => 'Doe',
]);
// 또는 POST 요청에서 자동 변환
public function store(UserData $data)
{
// $data는 자동으로 UserData 인스턴스
}
// 배열로 변환
$array = $data->toArray();
Route 속성 완전 가이드
use Spatie\RouteAttributes\Attributes\{
Get, Post, Patch, Delete, Prefix
};
#[Prefix('users')]
class UserController
{
// GET /users
#[Get('/', name: 'user.index')]
public function index() { }
// POST /users
#[Post('/', name: 'user.store')]
public function store(UserData $data) { }
// GET /users/{id}
#[Get('/{id}', name: 'user.show')]
public function show($id) { }
// PATCH /users/{id}
#[Patch('/{id}', name: 'user.update')]
public function update($id, UserData $data) { }
// DELETE /users/{id}
#[Delete('/{id}', name: 'user.destroy')]
public function destroy($id) { }
}
등록된 라우트 확인
php artisan route:list
모든 라우트가 명확히 표시됩니다.
개발 경험의 혁신
Java Spring을 경험한 PHP 개발자라면 이 방식이 얼마나 편한지 실감할 것입니다:
| 항목 | 이점 |
|---|---|
| DTO 중심 | 요청 데이터의 구조가 명확함 |
| 선언형 | Route 정의가 메서드 바로 위 |
| 유효성 검사 | 한 곳에서만 관리 |
| 타입 안정성 | IDE 자동완성과 타입 체크 |
고민: Spring처럼 변하면서의 선택
이렇게 Laravel이 Spring처럼 현대화되면서 가끔 이런 생각이 듭니다:
- “이렇게 Spring 스타일로 할 거라면 그냥 Spring을 할까?”
- “하지만 PHP의 유연성과 빠른 개발 속도는 여전히 매력적이다”
결국 프레임워크는 선택의 문제입니다.
마치며
Spatie의 laravel-data와 laravel-route-attributes는 PHP 8의 강력한 Attribute 기능을 활용하여:
- Laravel을 Spring만큼 현대적이고 우아하게 만들고
- DTO 관리의 복잡도를 크게 줄이며
- 팀 프로젝트에서 코드 일관성을 높입니다
특히 대규모 프로젝트나 팀 협업 상황에서 이 패턴의 가치가 두드러집니다.
지금 바로 시도해보세요.
댓글