Arquitectura de un proyecto de Laravel

Cuando un proyecto Laravel empieza a crecer, una de las primeras señales de desorden aparece en los controladores: métodos cada vez más largos, validaciones mezcladas con consultas, reglas de negocio dentro del mismo archivo y operaciones repetidas en varias partes del sistema.

Al inicio puede parecer práctico resolver todo dentro del controller, pero con el tiempo esa decisión vuelve más difícil mantener, probar y escalar el proyecto. Por eso conviene separar responsabilidades entre Controllers, Form Requests, Services y Models.

La idea no es complicar la arquitectura, sino poner cada pieza en el lugar correcto.


Resumen rápido

Una forma sencilla de entenderlo es esta:

ElementoResponsabilidad principal
ControllerControla el flujo HTTP: recibe la petición, llama lo necesario y responde.
Form RequestValida y prepara los datos antes de llegar al controller.
ServiceEjecuta la lógica de negocio o procesos importantes del sistema.
ModelRepresenta una tabla, relaciones, atributos, scopes y comportamiento simple de la entidad.

En otras palabras:

Request
  ↓
Form Request
  ↓
Controller
  ↓
Service
  ↓
Model / Base de datos
  ↓
Response

Qué es un Controller en Laravel

Un controller agrupa la lógica que responde a peticiones HTTP relacionadas con un recurso. Laravel recomienda usarlos para organizar el manejo de solicitudes que, de otra forma, terminarían como closures dentro de las rutas.

El controller debe actuar como coordinador. No debería cargar toda la lógica de negocio del sistema.

Responsabilidades recomendadas de un controller

  • Recibir la petición.
  • Usar un Form Request para validar.
  • Buscar un modelo cuando sea algo simple.
  • Llamar un Service cuando haya lógica de negocio.
  • Regresar una vista, redirect o respuesta JSON.

Ejemplo de controller limpio

public function update(UpdateProductRequest $request, string $product)
{
    $product = Product::find($product);

    $product = $this->productService->update(
        $product,
        $request->validated()
    );

    return response()->json([
        'success' => true,
        'message' => 'Producto actualizado correctamente.',
        'product' => [
            'id' => $product->id,
            'name' => $product->name,
        ],
    ]);
}

Este controller no sabe todos los detalles de cómo se actualiza el producto. Solo controla el flujo.

Eso lo hace más fácil de leer, mantener y modificar.


Qué es un Form Request

Un Form Request es una clase especializada para validar y autorizar datos de entrada. Laravel permite crear estos archivos para encapsular validación y autorización en un lugar separado del controller.

Esto evita que el controller se llene de reglas como required, exists, max, email, validaciones condicionales o preparación de datos.

Responsabilidades recomendadas de un Form Request

  • Validar campos requeridos.
  • Validar formatos.
  • Validar existencia de registros en base de datos.
  • Preparar datos antes de validar.
  • Autorizar si el usuario puede ejecutar la acción.

Ejemplo de Form Request

class UpdateProductRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'product_id' => ['required', 'integer', 'exists:products,id'],
            'name' => ['required', 'string', 'max:255'],
            'price' => ['required', 'numeric', 'min:0'],
        ];
    }

    public function authorize(): bool
    {
        return true;
    }
}

El controller ya no necesita preocuparse por esa preparación.


Qué es un Service

Un Service es una clase donde se concentra la lógica de negocio o los procesos importantes del sistema. Aunque Laravel no obliga a crear una carpeta Services, el framework sí facilita este patrón gracias a su Service Container y a la inyección de dependencias.

La pregunta clave para decidir si algo va a un Service es:

¿Esto solo controla una petición HTTP o representa una operación importante del negocio?

Si representa una operación del negocio, normalmente conviene moverlo a un Service.

Responsabilidades recomendadas de un Service

  • Crear registros.
  • Actualizar registros.
  • Eliminar registros.
  • Ejecutar transacciones.
  • Aplicar reglas de negocio.
  • Coordinar varias tablas.
  • Calcular importes, descuentos, comisiones o estados.
  • Consumir APIs externas.
  • Disparar notificaciones, eventos o procesos relacionados.

Ejemplo de Service

class ProductService
{
    public function create(array $data): Product
    {
        return DB::transaction(function () use ($data) {
            return Product::create([
                'name' => $data['name'],
                'description' => $data['description'] ?? null,
                'price' => $data['price'],
                'enabled' => $data['enabled'] ?? true,
            ]);
        });
    }

    public function update(Product $product, array $data): Product
    {
        return DB::transaction(function () use ($product, $data) {
            $product->name = $data['name'];
            $product->description = $data['description'] ?? null;
            $product->price = $data['price'];
            $product->enabled = $data['enabled'] ?? $product->enabled;
            $product->save();

            return $product->fresh();
        });
    }

    public function delete(Product $product): void
    {
        DB::transaction(function () use ($product) {
            $product->delete();
        });
    }
}

Aunque algunos métodos parezcan simples al inicio, tenerlos en un Service permite que crezcan ordenadamente. Hoy un delete() puede borrar un registro; mañana puede validar permisos internos, eliminar relaciones, registrar historial y disparar una notificación.


Qué es un Model en Laravel

Un Model representa una tabla de base de datos dentro de Eloquent. Laravel indica que cada tabla suele tener un modelo correspondiente, y que estos modelos permiten consultar, insertar, actualizar y eliminar registros.

Pero eso no significa que toda la lógica de negocio deba vivir dentro del Model.

Responsabilidades recomendadas de un Model

  • Definir la tabla si no sigue la convención.
  • Definir campos fillable o guarded.
  • Definir relaciones.
  • Definir casts.
  • Crear accessors y mutators.
  • Crear scopes reutilizables.
  • Agregar métodos simples propios de la entidad.

Ejemplo de Model

class Product extends Model
{
    protected $fillable = [
        'name',
        'description',
        'price',
        'enabled',
    ];

    protected function casts(): array
    {
        return [
            'price' => 'decimal:2',
            'enabled' => 'boolean',
        ];
    }

    public function scopeEnabled($query)
    {
        return $query->where('enabled', true);
    }

    public function isAvailable(): bool
    {
        return $this->enabled === true;
    }
}

Este tipo de lógica sí pertenece al modelo porque describe el comportamiento directo de la entidad Product.

En cambio, si el proceso implica cobrar, registrar historial, enviar notificaciones y actualizar varias tablas, eso ya debería ir a un Service.


Cuándo usar cada uno

Usa Controller cuando…

  • Necesitas recibir una petición HTTP.
  • Necesitas devolver una vista.
  • Necesitas devolver JSON.
  • Necesitas redireccionar.
  • La acción solo coordina otras piezas.
public function index()
{
    $products = Product::enabled()
        ->orderBy('name')
        ->paginate(20);

    return view('products.index', compact('products'));
}

Una consulta sencilla para mostrar datos puede quedarse en el controller.

Usa Form Request cuando…

  • Hay reglas de validación.
  • Hay autorización de la acción.
  • Necesitas preparar datos antes de validarlos.
  • Quieres evitar que el controller se llene de reglas.
public function rules(): array
{
    return [
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'email'],
    ];
}

Usa Service cuando…

  • La acción crea, actualiza o elimina información importante.
  • La acción toca varias tablas.
  • La lógica se reutilizará desde varios lugares.
  • Hay transacciones.
  • Hay integración con APIs externas.
  • Hay reglas de negocio.
$order = $this->orderService->create($request->validated());

Usa Model cuando…

  • Necesitas definir relaciones.
  • Necesitas casts.
  • Necesitas scopes.
  • Necesitas accessors o mutators.
  • Necesitas métodos simples de la entidad.
public function scopeEnabled($query)
{
    return $query->where('enabled', true);
}

Ejemplo completo: creación de una orden

Supongamos que tenemos un sistema donde un cliente puede crear una orden. La orden debe guardar productos, calcular total, registrar pagos pendientes y devolver una respuesta.

Si todo se mete en el controller, el método puede volverse enorme. Una mejor separación sería:

Ruta

Route::post('/orders', [OrderController::class, 'store'])
    ->name('orders.store');

Controller

class OrderController extends Controller
{
    public function __construct(
        private readonly OrderService $orderService
    ) {}

    public function store(StoreOrderRequest $request)
    {
        $order = $this->orderService->create($request->validated());

        return response()->json([
            'success' => true,
            'message' => 'Orden creada correctamente.',
            'order' => [
                'id' => $order->id,
                'total' => $order->total,
            ],
        ]);
    }
}

Form Request

class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_name' => ['required', 'string', 'max:255'],
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
            'items.*.quantity' => ['required', 'integer', 'min:1'],
        ];
    }
}

Service

class OrderService
{
    public function create(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = Order::create([
                'customer_name' => $data['customer_name'],
                'total' => 0,
                'status' => 'pending',
            ]);

            $total = 0;

            foreach ($data['items'] as $item) {
                $product = Product::findOrFail($item['product_id']);
                $subtotal = $product->price * $item['quantity'];

                $order->items()->create([
                    'product_id' => $product->id,
                    'quantity' => $item['quantity'],
                    'unit_price' => $product->price,
                    'subtotal' => $subtotal,
                ]);

                $total += $subtotal;
            }

            $order->update([
                'total' => $total,
            ]);

            return $order->fresh();
        });
    }
}

Model

class Order extends Model
{
    protected $fillable = [
        'customer_name',
        'total',
        'status',
    ];

    protected function casts(): array
    {
        return [
            'total' => 'decimal:2',
        ];
    }

    public function items()
    {
        return $this->hasMany(OrderItem::class);
    }

    public function isPending(): bool
    {
        return $this->status === 'pending';
    }
}

Con esta estructura, cada clase tiene una responsabilidad clara.

El controller no calcula totales. El Form Request no guarda datos. El Service no genera respuestas HTTP. El Model no se encarga de coordinar todo el proceso.


Errores comunes al separar responsabilidades

1. Crear Services para todo

No todas las acciones necesitan un Service. Si solo vas a mostrar una vista con una consulta sencilla, el controller puede hacerlo.

2. Meter validaciones en el Service

La validación de entrada normalmente debe vivir en un Form Request. El Service debe recibir datos ya validados y ejecutar el proceso.

3. Regresar respuestas HTTP desde el Service

El Service no debería regresar response()->json() ni redirects. Eso le corresponde al controller.

4. Convertir el Model en una clase gigante

El Model debe representar la entidad, pero no coordinar procesos completos del sistema. Si una operación toca varias entidades o APIs externas, probablemente debe vivir en un Service.

5. No usar transacciones en procesos críticos

Cuando una operación crea o actualiza varios registros relacionados, conviene usar transacciones para evitar datos incompletos si algo falla a mitad del proceso.


Regla práctica para proyectos Laravel

Una convención sencilla para mantener el orden es esta:

Si una acción crea, actualiza o elimina información importante de la base de datos, o representa un proceso del negocio, la lógica principal debe ir en un Service.

Esto no significa que el controller nunca pueda consultar modelos. Para listados simples, vistas de edición o pantallas informativas, puede ser totalmente válido.

Pero cuando el método empieza a tener transacciones, ciclos, cálculos, integraciones, actualizaciones en varias tablas o condiciones del negocio, es momento de mover esa lógica a un Service.

Guía rápida

SituaciónDónde colocarlo
Mostrar una lista simpleController
Validar campos de formularioForm Request
Crear un registro con reglas internasService
Actualizar varias tablasService
Consumir una API externaService
Definir relación belongsToModel
Crear accessor o castModel
Regresar JSONController
Redireccionar a otra rutaController

Conclusión

Separar Controllers, Form Requests, Services y Models no es solo una cuestión de estilo. Es una forma de mantener proyectos Laravel más claros, escalables y fáciles de modificar.

Un controller debe controlar el flujo de la petición. Un Form Request debe validar y preparar datos. Un Service debe ejecutar la lógica de negocio. Un Model debe representar la entidad y su relación con la base de datos.

La meta no es crear más archivos sin razón, sino evitar que una sola clase tenga demasiadas responsabilidades.

Cuando cada pieza cumple su función, el código se vuelve más fácil de leer, probar y mantener.

Fuentes consultadas

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *