laravelarquitecturaestructurabuenas-practicas

Cómo estructurar un proyecto Laravel grande correctamente

La estructura por defecto de Laravel funciona perfectamente para proyectos pequeños y medianos. Pero cuando tu aplicación tiene 50 modelos, 100 controladores y lógica de negocio compleja, el directorio app/ se convierte en un caos. Hablemos de cómo organizar un proyecto Laravel a medida que escala.

Las limitaciones de la estructura por defecto

La estructura estándar de Laravel pone todo en pocas carpetas:

app/
├── Http/
│   ├── Controllers/     ← Pueden ser 50+ archivos
│   ├── Requests/        ← 100+ archivos
│   └── Middleware/
├── Models/              ← 30+ modelos
└── Providers/

Cuando tienes controladores enormes con 500 líneas que mezclan validación, lógica de negocio y acceso a datos, algo está mal. La solución no es abandonar la estructura de Laravel, sino añadir capas.

Fat controllers: el antipatrón más común

// MAL: controlador con toda la lógica
class OrderController extends Controller
{
    public function store(Request $request)
    {
        // Validación
        $validated = $request->validate([...]);
        
        // Lógica de negocio (50 líneas)
        $cart = Cart::find($request->cart_id);
        $discount = $this->calculateDiscount($cart, $request->coupon_code);
        $tax = $this->calculateTax($cart->total - $discount, $request->country);
        
        // Acceso a datos (30 líneas)
        $order = Order::create([...]);
        foreach ($cart->items as $item) {
            $order->items()->create([...]);
        }
        
        // Efectos secundarios (20 líneas)
        Mail::to($request->user())->send(new OrderConfirmation($order));
        $cart->delete();
        
        return redirect('/orders/'.$order->id);
    }
}

La capa de Actions: un método, una responsabilidad

El patrón Action es una clase con un solo método público que hace una sola cosa:

// app/Actions/Orders/CreateOrder.php
<?php

namespace App\Actions\Orders;

use App\Models\Cart;
use App\Models\Order;
use App\Models\User;

class CreateOrder
{
    public function __construct(
        private CalculateDiscount $calculateDiscount,
        private CalculateTax $calculateTax,
    ) {}

    public function execute(User $user, Cart $cart, array $data): Order
    {
        $discount = $this->calculateDiscount->execute($cart, $data['coupon_code'] ?? null);
        $tax      = $this->calculateTax->execute($cart->total - $discount, $data['country']);

        $order = Order::create([
            'user_id'  => $user->id,
            'subtotal' => $cart->total,
            'discount' => $discount,
            'tax'      => $tax,
            'total'    => $cart->total - $discount + $tax,
            'country'  => $data['country'],
        ]);

        foreach ($cart->items as $item) {
            $order->items()->create([
                'product_id' => $item->product_id,
                'quantity'   => $item->quantity,
                'price'      => $item->price,
            ]);
        }

        return $order;
    }
}
// El controlador queda limpio
class OrderController extends Controller
{
    public function __construct(
        private CreateOrder $createOrder
    ) {}

    public function store(StoreOrderRequest $request)
    {
        $order = $this->createOrder->execute(
            user: $request->user(),
            cart: Cart::findOrFail($request->cart_id),
            data: $request->validated(),
        );
        
        OrderCompleted::dispatch($order); // El email va en un listener

        return redirect()->route('orders.show', $order)
                         ->with('success', 'Pedido creado correctamente');
    }
}

Form Requests: validación fuera del controlador

Las Form Requests mueven la validación del controlador a su propia clase:

// app/Http/Requests/StoreOrderRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Autorización aquí también si es necesario
        return $this->user()->can('create', Order::class);
    }

    public function rules(): array
    {
        return [
            'cart_id'     => 'required|exists:carts,id',
            'country'     => 'required|string|size:2',
            'coupon_code' => 'nullable|string|exists:coupons,code',
            'address'     => 'required|string|max:255',
        ];
    }

    public function messages(): array
    {
        return [
            'cart_id.exists'     => 'El carrito no existe o ya fue procesado',
            'coupon_code.exists' => 'El código de descuento no es válido',
        ];
    }
}

DTOs (Data Transfer Objects): tipado fuerte para datos

Los DTOs son objetos simples que representan datos con tipos definidos:

// app/DTOs/CreateOrderDTO.php
<?php

namespace App\DTOs;

readonly class CreateOrderDTO
{
    public function __construct(
        public int    $userId,
        public int    $cartId,
        public string $country,
        public string $address,
        public ?string $couponCode = null,
    ) {}

    public static function fromRequest(StoreOrderRequest $request): self
    {
        return new self(
            userId:     $request->user()->id,
            cartId:     $request->input('cart_id'),
            country:    $request->input('country'),
            address:    $request->input('address'),
            couponCode: $request->input('coupon_code'),
        );
    }
}

Los DTOs con readonly (PHP 8.1+) son perfectos: inmutables y con tipado explícito.

La capa de Services: lógica de dominio compleja

Para lógica más compleja que involucra múltiples modelos y servicios:

// app/Services/PaymentService.php
<?php

namespace App\Services;

use App\Contracts\PaymentGateway;
use App\Models\Order;
use App\Models\Payment;

class PaymentService
{
    public function __construct(
        private PaymentGateway $gateway
    ) {}

    public function processPayment(Order $order, string $paymentMethod): Payment
    {
        $result = $this->gateway->charge(
            amount:   $order->total,
            currency: 'EUR',
            method:   $paymentMethod,
            metadata: ['order_id' => $order->id],
        );

        $payment = Payment::create([
            'order_id'       => $order->id,
            'amount'         => $order->total,
            'transaction_id' => $result->transactionId,
            'status'         => $result->status,
            'gateway'        => config('services.payment.driver'),
        ]);

        if ($result->failed()) {
            throw new PaymentFailedException($result->errorMessage);
        }

        $order->update(['payment_status' => 'paid', 'status' => 'processing']);

        return $payment;
    }
}

API Resources: transformar modelos para la API

Las API Resources evitan exponer la estructura interna de tus modelos:

// app/Http/Resources/OrderResource.php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'status'     => $this->status,
            'total'      => number_format($this->total, 2),
            'currency'   => 'EUR',
            'created_at' => $this->created_at->format('d/m/Y H:i'),
            
            // Incluir items solo si están cargados
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
            
            // Incluir solo si hay permiso
            'payment_details' => $this->when(
                $request->user()?->isAdmin(),
                fn() => [
                    'transaction_id' => $this->payment->transaction_id,
                    'gateway'        => $this->payment->gateway,
                ]
            ),
        ];
    }
}

Organización por dominio para proyectos grandes

En proyectos muy grandes, puedes organizar por dominio en lugar de por tipo de clase:

app/
├── Domain/
│   ├── Orders/
│   │   ├── Actions/
│   │   │   ├── CreateOrder.php
│   │   │   └── CancelOrder.php
│   │   ├── DTOs/
│   │   │   └── CreateOrderDTO.php
│   │   ├── Events/
│   │   │   └── OrderCompleted.php
│   │   ├── Listeners/
│   │   │   └── SendOrderConfirmation.php
│   │   └── Models/
│   │       └── Order.php
│   ├── Users/
│   │   ├── Actions/
│   │   ├── Models/
│   │   └── Services/
│   └── Payments/
│       ├── Contracts/
│       │   └── PaymentGateway.php
│       ├── Gateways/
│       │   ├── StripeGateway.php
│       │   └── PayPalGateway.php
│       └── Services/
│           └── PaymentService.php
├── Http/
│   ├── Controllers/
│   ├── Requests/
│   └── Resources/
└── Providers/

Esta estructura hace que todo lo relacionado con “Orders” esté junto, facilitando el mantenimiento.

Cuándo añadir cada capa

No necesitas todas las capas desde el principio. Una guía práctica:

  • Form Requests: desde el primer controlador con validación
  • Actions: cuando un método de controlador supera 30-40 líneas
  • Services: cuando la lógica involucra múltiples modelos o llamadas externas
  • DTOs: cuando pasas muchos parámetros entre capas o necesitas tipado fuerte
  • Repositorios: raramente necesarios en Laravel (Eloquent ya es un repositorio)
  • Organización por dominio: cuando tienes 10+ modelos relacionados temáticamente

Conclusión

La clave es no añadir complejidad prematuramente. Empieza con la estructura por defecto de Laravel, añade Form Requests inmediatamente, y cuando los controladores empiecen a crecer, introduce Actions. Los Services y DTOs llegan cuando la lógica de negocio se complica. Un proyecto organizado no es el que usa todos los patrones, sino el que usa los patrones correctos para su tamaño y complejidad actual.