Cómo estructurar un proyecto Laravel para que escale
Más allá de la estructura MVC por defecto: cómo organizar un proyecto Laravel con Clean Architecture, servicios de dominio y separación de capas que aguanta el crecimiento.
Laravel viene con opiniones sobre cómo organizar el código. Por defecto: controladores, modelos, migraciones, y poco más. Para un proyecto pequeño, está perfectamente bien. Para un sistema que va a crecer durante años, esa estructura empieza a mostrar sus límites pronto.
Este artículo documenta la organización que uso en proyectos Laravel de producción de mediana y gran escala. No es dogma ni Clean Architecture pura — es lo que funciona en la práctica.
El problema con la estructura por defecto
En un proyecto Laravel estándar, la lógica de negocio tiende a acumularse en tres sitios:
- Controladores que hacen demasiado (validación, lógica de negocio, persistencia)
- Modelos con métodos de negocio mezclados con lógica de persistencia
- “Services” planos que son básicamente clases sin estructura clara
El síntoma: cuando algo falla en producción, es difícil saber dónde está el bug. Cuando tienes que añadir una feature, no está claro dónde va el código. Cuando quieres testear la lógica de negocio, tienes que mockear la base de datos.
La estructura que uso
app/
├── Http/
│ ├── Controllers/ # Solo coordinan: validan input, llaman a Actions, devuelven response
│ ├── Requests/ # Form requests con validación
│ └── Resources/ # API resources (transformadores de output)
│
├── Domain/ # Lógica de negocio pura
│ ├── Booking/
│ │ ├── Actions/ # Casos de uso (CreateBooking, CancelBooking, etc.)
│ │ ├── DTOs/ # Data Transfer Objects (inmutables)
│ │ ├── Events/ # Eventos de dominio
│ │ ├── Exceptions/ # Excepciones específicas del dominio
│ │ └── Contracts/ # Interfaces de repositorio
│ └── Payment/
│ ├── Actions/
│ └── ...
│
├── Infrastructure/ # Implementaciones concretas de contratos del dominio
│ ├── Repositories/ # Implementaciones Eloquent de los contratos
│ ├── Services/ # Clientes de servicios externos (Stripe, AWS, etc.)
│ └── Queue/ # Jobs y Listeners
│
└── Models/ # Modelos Eloquent (solo persistencia, sin lógica de negocio)
Los componentes clave
Actions: casos de uso explícitos
En lugar de services genéricos, cada caso de uso tiene su propia clase. Hace que el código sea auto-documentado:
// app/Domain/Booking/Actions/CreateBookingAction.php
final class CreateBookingAction
{
public function __construct(
private readonly BookingRepositoryContract $bookings,
private readonly AvailabilityServiceContract $availability,
private readonly EventDispatcherContract $events,
) {}
public function execute(CreateBookingData $data): Booking
{
// 1. Verificar disponibilidad
if (!$this->availability->isAvailable($data->activityId, $data->date, $data->participants)) {
throw new ActivityNotAvailableException($data->activityId, $data->date);
}
// 2. Crear reserva en estado pendiente
$booking = $this->bookings->create([
'user_id' => $data->userId,
'activity_id' => $data->activityId,
'date' => $data->date,
'participants' => $data->participants,
'amount' => $data->amount,
'status' => BookingStatus::PENDING,
]);
// 3. Disparar evento de dominio
$this->events->dispatch(new BookingCreated($booking));
return $booking;
}
}
El controlador queda limpio:
class BookingController extends Controller
{
public function store(CreateBookingRequest $request, CreateBookingAction $action): JsonResponse
{
$booking = $action->execute(
CreateBookingData::fromRequest($request)
);
return BookingResource::make($booking)->response()->setStatusCode(201);
}
}
DTOs: datos tipados entre capas
Los Data Transfer Objects eliminan los arrays mágicos y documentan exactamente qué datos fluyen entre capas:
// app/Domain/Booking/DTOs/CreateBookingData.php
final readonly class CreateBookingData
{
public function __construct(
public readonly int $userId,
public readonly int $activityId,
public readonly Carbon $date,
public readonly int $participants,
public readonly Money $amount,
) {}
public static function fromRequest(CreateBookingRequest $request): self
{
return new self(
userId: $request->user()->id,
activityId: $request->integer('activity_id'),
date: Carbon::parse($request->string('date')),
participants: $request->integer('participants'),
amount: Money::fromDecimal($request->string('amount'), 'EUR'),
);
}
}
Con readonly en PHP 8.1+, los DTOs son inmutables por construcción.
Contratos de repositorio
Definir las interfaces del repositorio en el dominio, implementarlas en infraestructura:
// app/Domain/Booking/Contracts/BookingRepositoryContract.php
interface BookingRepositoryContract
{
public function findById(int $id): ?Booking;
public function findByUser(int $userId, array $filters = []): Collection;
public function create(array $data): Booking;
public function updateStatus(int $id, BookingStatus $status): Booking;
}
// app/Infrastructure/Repositories/EloquentBookingRepository.php
final class EloquentBookingRepository implements BookingRepositoryContract
{
public function findById(int $id): ?Booking
{
return BookingModel::find($id)?->toDomain();
}
public function create(array $data): Booking
{
return BookingModel::create($data)->toDomain();
}
// ...
}
Esto permite testear las Actions con un repositorio en memoria, sin base de datos:
// Tests rápidos de lógica de negocio
class CreateBookingActionTest extends TestCase
{
public function test_creates_booking_when_activity_is_available(): void
{
$action = new CreateBookingAction(
bookings: new InMemoryBookingRepository(),
availability: new AlwaysAvailableService(),
events: new FakeEventDispatcher(),
);
$booking = $action->execute(CreateBookingData::fake());
$this->assertEquals(BookingStatus::PENDING, $booking->status);
}
}
Binding en un Service Provider
// app/Providers/DomainServiceProvider.php
class DomainServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
BookingRepositoryContract::class,
EloquentBookingRepository::class,
);
$this->app->bind(
AvailabilityServiceContract::class,
SidetourAvailabilityService::class,
);
}
}
Cuándo no usar esta estructura
Esta organización tiene un coste inicial. Para un proyecto pequeño (< 10 entidades, 1-2 developers, corta vida útil esperada), la estructura por defecto de Laravel es completamente válida.
Los indicadores de que sí merece la pena:
- El proyecto va a vivir más de 1-2 años
- Más de un developer trabajará en él
- La lógica de negocio es compleja o crecerá
- Hay requisito de tests de negocio sin base de datos
Conclusión
La estructura que propongo no es perfecta ni única. Es la que ha funcionado bien en proyectos de producción donde la lógica de negocio era suficientemente compleja como para justificar la inversión inicial.
Lo que más valor aporta en la práctica:
- Actions por caso de uso — el código es auto-documentado y fácil de encontrar
- DTOs inmutables — elimina errores de arrays mágicos entre capas
- Contratos de repositorio — hace el código testeable sin infraestructura real
Laravel es un framework excelente que no se opone a estas abstracciones. Solo tienes que añadirlas tú.