| name | laravel-patterns |
| description | 운영용 애플리케이션을 위한 Laravel 아키텍처 패턴, 라우팅/컨트롤러, Eloquent ORM, 서비스 레이어, 큐, 이벤트, 캐싱, API 리소스를 다룹니다. |
| origin | ECC |
Laravel 개발 패턴
확장 가능하고 유지보수 가능한 애플리케이션을 위한 운영 수준 Laravel 아키텍처 패턴입니다.
사용 시점
- Laravel 웹 애플리케이션이나 API를 만들 때
- 컨트롤러, 서비스, 도메인 로직을 구조화할 때
- Eloquent 모델과 관계를 다룰 때
- 리소스와 페이지네이션을 활용한 API를 설계할 때
- 큐, 이벤트, 캐시, 백그라운드 작업을 추가할 때
동작 방식
- 앱을 명확한 경계(controllers -> services/actions -> models) 중심으로 구조화합니다.
- 라우팅 예측 가능성을 위해 명시적 바인딩과 스코프드 바인딩을 사용하고, 접근 제어를 위해 권한 검사를 별도로 강제합니다.
- 타입이 있는 모델, cast, scope를 활용해 도메인 로직 일관성을 유지합니다.
- I/O가 무거운 작업은 큐로 분리하고, 비싼 읽기는 캐시합니다.
- 설정은
config/*에 중앙화하고, 환경 차이는 명시적으로 유지합니다.
예시
프로젝트 구조
명확한 계층 경계를 가진 전형적인 Laravel 레이아웃을 사용합니다. HTTP, services/actions, models를 분리합니다.
권장 레이아웃
app/
├── Actions/ # Single-purpose use cases
├── Console/
├── Events/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ ├── Requests/ # Form request validation
│ └── Resources/ # API resources
├── Jobs/
├── Models/
├── Policies/
├── Providers/
├── Services/ # Coordinating domain services
└── Support/
config/
database/
├── factories/
├── migrations/
└── seeders/
resources/
├── views/
└── lang/
routes/
├── api.php
├── web.php
└── console.php
Controllers -> Services -> Actions
컨트롤러는 얇게 유지합니다. 오케스트레이션은 service에, 단일 목적 로직은 action에 둡니다.
final class CreateOrderAction
{
public function __construct(private OrderRepository $orders) {}
public function handle(CreateOrderData $data): Order
{
return $this->orders->create($data);
}
}
final class OrdersController extends Controller
{
public function __construct(private CreateOrderAction $createOrder) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->createOrder->handle($request->toDto());
return response()->json([
'success' => true,
'data' => OrderResource::make($order),
'error' => null,
'meta' => null,
], 201);
}
}
라우팅과 컨트롤러
가독성과 안정성을 위해 route-model binding과 resource controller를 우선합니다.
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('projects', ProjectController::class);
});
Route Model Binding(Scoped)
테넌트 간 교차 접근을 막기 위해 scoped binding을 사용합니다.
Route::scopeBindings()->group(function () {
Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);
});
중첩 라우트와 바인딩 이름
- prefix와 path는 일관되게 유지해 중복 중첩을 피합니다. 예:
conversation vs conversations
- 바운드 모델과 맞는 단일 파라미터 이름을 사용합니다. 예:
Conversation에는 {conversation}
- 중첩 라우트에서는 부모-자식 관계 강제를 위해 scoped binding을 우선합니다.
use App\Http\Controllers\Api\ConversationController;
use App\Http\Controllers\Api\MessageController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->prefix('conversations')->group(function () {
Route::post('/', [ConversationController::class, 'store'])->name('conversations.store');
Route::scopeBindings()->group(function () {
Route::get('/{conversation}', [ConversationController::class, 'show'])
->name('conversations.show');
Route::post('/{conversation}/messages', [MessageController::class, 'store'])
->name();
::(, [::, ])
->();
});
});
파라미터를 다른 모델 클래스로 매핑하고 싶다면 명시적 바인딩을 정의합니다. 더 커스텀한 로직이 필요하면 Route::bind() 또는 모델의 resolveRouteBinding()을 사용합니다.
use App\Models\AiConversation;
use Illuminate\Support\Facades\Route;
Route::model('conversation', AiConversation::class);
서비스 컨테이너 바인딩
의존성 연결을 명확하게 하기 위해 service provider에서 인터페이스와 구현체를 바인딩합니다.
use App\Repositories\EloquentOrderRepository;
use App\Repositories\OrderRepository;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
}
Eloquent 모델 패턴
모델 설정
final class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'owner_id', 'status'];
protected $casts = [
'status' => ProjectStatus::class,
'archived_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
}
커스텀 Cast와 값 객체
엄격한 타입 관리를 위해 enum이나 값 객체를 사용합니다.
use Illuminate\Database\Eloquent\Casts\Attribute;
protected $casts = [
'status' => ProjectStatus::class,
];
protected function budgetCents(): Attribute
{
return Attribute::make(
get: fn (int $value) => Money::fromCents($value),
set: fn (Money $money) => $money->toCents(),
);
}
N+1 방지를 위한 Eager Loading
$orders = Order::query()
->with(['customer', 'items.product'])
->latest()
->paginate(25);
복잡한 필터를 위한 Query Object
final class ProjectQuery
{
public function __construct(private Builder $query) {}
public function ownedBy(int $userId): self
{
$query = clone $this->query;
return new self($query->where('owner_id', $userId));
}
public function active(): self
{
$query = clone $this->query;
return new self($query->whereNull('archived_at'));
}
public function builder(): Builder
{
return $this->query;
}
}