Patrones de Desarrollo en Laravel
Patrones de arquitectura de nivel productivo para aplicaciones Laravel escalables y mantenibles.
Cuándo Usarlo
- Construyendo aplicaciones web Laravel o APIs
- Estructurando controladores, servicios y lógica de dominio
- Trabajando con modelos Eloquent y relaciones
- Diseñando APIs con recursos y paginación
- Agregando colas, eventos, caché y trabajos en segundo plano
Cómo Funciona
- Estructura la aplicación alrededor de límites claros (controladores -> servicios/acciones -> modelos).
- Usa vinculaciones explícitas y vinculaciones con alcance para mantener el enrutamiento predecible; aún así aplica autorización para el control de acceso.
- Favorece modelos tipados, casts y scopes para mantener la lógica de dominio consistente.
- Mantén el trabajo pesado de E/S en colas y almacena en caché las lecturas costosas.
- Centraliza la configuración en
config/*y mantén los entornos explícitos.
Ejemplos
Estructura del Proyecto
Usa un diseño Laravel convencional con límites de capa claros (HTTP, servicios/acciones, modelos).
Diseño Recomendado
app/
├── Actions/ # Casos de uso de un solo propósito
├── Console/
├── Events/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ ├── Requests/ # Validación de Form Requests
│ └── Resources/ # Recursos de API
├── Jobs/
├── Models/
├── Policies/
├── Providers/
├── Services/ # Servicios de coordinación de dominio
└── Support/
config/
database/
├── factories/
├── migrations/
└── seeders/
resources/
├── views/
└── lang/
routes/
├── api.php
├── web.php
└── console.php
Controladores -> Servicios -> Acciones
Mantén los controladores delgados. Coloca la orquestación en servicios y la lógica de un solo propósito en acciones.
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);
}
}
Enrutamiento y Controladores
Prefiere la vinculación de modelo en ruta y controladores de recursos para mayor claridad.
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('projects', ProjectController::class);
});
Vinculación de Modelo en Ruta (Con Alcance)
Usa vinculaciones con alcance para prevenir acceso entre inquilinos.
Route::scopeBindings()->group(function () {
Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);
});
Rutas Anidadas y Nombres de Vinculación
- Mantén prefijos y rutas consistentes para evitar doble anidamiento (por ejemplo,
conversationvsconversations). - Usa un solo nombre de parámetro que coincida con el modelo vinculado (por ejemplo,
{conversation}paraConversation). - Prefiere las vinculaciones con alcance al anidar para aplicar relaciones padre-hijo.
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('conversation-messages.store');
Route::get('/{conversation}/messages/{message}', [MessageController::class, 'show'])
->name('conversation-messages.show');
});
});
Si deseas que un parámetro resuelva una clase de modelo diferente, define una vinculación explícita. Para lógica de vinculación personalizada, usa Route::bind() o implementa resolveRouteBinding() en el modelo.
use App\Models\AiConversation;
use Illuminate\Support\Facades\Route;
Route::model('conversation', AiConversation::class);
Vinculaciones en el Contenedor de Servicios
Vincula interfaces a implementaciones en un proveedor de servicios para un cableado de dependencias claro.
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);
}
}
Patrones de Modelos Eloquent
Configuración del Modelo
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');
}
}
Casts Personalizados y Objetos de Valor
Usa enums u objetos de valor para tipado estricto.
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(),
);
}
Carga Anticipada para Evitar N+1
$orders = Order::query()
->with(['customer', 'items.product'])
->latest()
->paginate(25);
Objetos de Consulta para Filtros Complejos
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;
}
}
Scopes Globales y Eliminaciones Suaves
Usa scopes globales para filtrado predeterminado y SoftDeletes para registros recuperables.
Usa ya sea un scope global o un scope con nombre para el mismo filtro, no ambos, a menos que pretendas un comportamiento en capas.
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder): void {
$builder->whereNull('archived_at');
});
}
}
Scopes de Consulta para Filtros Reutilizables
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('owner_id', $userId);
}
}
// En servicio, repositorio, etc.
$projects = Project::ownedBy($user->id)->get();
Transacciones para Actualizaciones de Múltiples Pasos
use Illuminate\Support\Facades\DB;
DB::transaction(function (): void {
$order->update(['status' => 'paid']);
$order->items()->update(['paid_at' => now()]);
});
Migraciones
Convención de Nombres
- Los nombres de archivo usan marcas de tiempo:
YYYY_MM_DD_HHMMSS_create_users_table.php - Las migraciones usan clases anónimas (sin clase nombrada); el nombre del archivo comunica la intención
- Los nombres de las tablas son
snake_casey en plural por defecto
Ejemplo de Migración
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->string('status', 32)->index();
$table->unsignedInteger('total_cents');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Form Requests y Validación
Mantén la validación en form requests y transforma las entradas en DTOs.
use App\Models\Order;
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Order::class) ?? false;
}
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}
public function toDto(): CreateOrderData
{
return new CreateOrderData(
customerId: (int) $this->validated('customer_id'),
items: $this->validated('items'),
);
}
}
Recursos de API
Mantén las respuestas de la API consistentes con recursos y paginación.
$projects = Project::query()->active()->paginate(25);
return response()->json([
'success' => true,
'data' => ProjectResource::collection($projects->items()),
'error' => null,
'meta' => [
'page' => $projects->currentPage(),
'per_page' => $projects->perPage(),
'total' => $projects->total(),
],
]);
Eventos, Trabajos y Colas
- Emite eventos de dominio para efectos secundarios (correos electrónicos, analíticas)
- Usa trabajos en cola para trabajo lento (reportes, exportaciones, webhooks)
- Prefiere manejadores idempotentes con reintentos y retroceso
Caché
- Almacena en caché los endpoints de lectura intensiva y las consultas costosas
- Invalida cachés en eventos de modelo (created/updated/deleted)
- Usa etiquetas al almacenar en caché datos relacionados para una invalidación fácil
Configuración y Entornos
- Mantén los secretos en
.envy la configuración enconfig/*.php - Usa anulaciones de configuración por entorno y
config:cacheen producción