[Guía práctica completa] Laravel Service Container y Dependency Injection — Patrones prácticos para mejorar el diseño mantenible, las capas de servicio, la separación por interfaces y la capacidad de prueba
Lo que aprenderás en este artículo (puntos clave)
- Cómo entender los fundamentos del service container y dependency injection de Laravel a un nivel lo bastante práctico para el trabajo real
- Cómo lidiar con controladores sobrecargados de lógica organizando el código con capas de servicio y Actions
- Cómo diseñar en torno a interfaces en lugar de clases concretas, y cómo usar
bind/singleton/scopedde forma apropiada - Patrones de implementación para desacoplar procesos que probablemente querrás sustituir más adelante, como APIs externas, correo, pagos y almacenamiento de archivos
- Cómo mejorar la capacidad de prueba con el container, incluido cómo reemplazar dependencias con Fakes y Mocks
- Cómo evitar la sobreabstracción en la que suelen caer las personas principiantes, sin dejar de hacer crecer una app Laravel hacia algo legible y mantenible
- Cómo este diseño también favorece un manejo accesible de errores y la visualización de resultados en pantallas administrativas, formularios y UIs de notificación
Lectores previstos
- Ingenieros Laravel de nivel principiante a intermedio: personas cuyos controladores y modelos se están sobrecargando y no tienen claro cómo organizar su código
- Tech leads: personas que quieren establecer reglas de equipo sobre “hasta dónde abstraer”
- Ingenieros de QA / mantenimiento: personas que quieren reemplazar con más facilidad APIs externas y lógica de notificaciones, y facilitar las pruebas y la respuesta ante incidentes
- Diseñadores / atención al cliente / personal de operaciones: personas que quieren una base estable para resultados en pantalla y mensajes de error, de modo que disminuyan las consultas
Nivel de accesibilidad: ★★★★☆
El tema principal es el diseño backend, pero una vez que las responsabilidades están correctamente separadas, los estados de éxito / fallo / advertencia que se devuelven a la UI se vuelven más estables. Como resultado, se vuelve más fácil construir notificaciones consistentes usando
role="status"yrole="alert", aclarar errores de formularios y diseñar mensajes que no dependan solo del color.
1. Introducción: una vez que entiendes Dependency Injection, el código Laravel se vuelve de repente mucho más fácil de leer
Cuando empiezas con Laravel, es perfectamente posible escribir la lógica directamente dentro de un controlador. Registro, actualizaciones, envío de correos, integración con APIs externas, logging: aunque pongas todo en un método por ahora, igual puedes construir la pantalla. Pero a medida que las funcionalidades crecen, poco a poco empiezas a encontrarte con problemas como estos:
- Los controladores se hacen largos y se vuelve difícil saber dónde hacer cambios
- La misma lógica queda dispersa en varios lugares, lo que provoca correcciones omitidas
- Reemplazar una API externa se vuelve doloroso
- Quieres sustituir algo por un Mock o un Fake en las pruebas, pero lo instanciaste con
new, así que cambiarlo es difícil - Cuando fallan los pagos o las notificaciones, se vuelve difícil rastrear el alcance del impacto
Aquí es donde el service container y dependency injection empiezan a ayudar. Al principio puede sonar abstracto, pero en términos simples es solo un mecanismo para “pasar las piezas que necesitas a los lugares que las necesitan de una manera que facilite su reemplazo”. Laravel ofrece un gran soporte para esto desde el principio, así que una vez que entiendes cómo usarlo, tanto la mantenibilidad como la capacidad de prueba mejoran de forma notable.
2. Primero, entiende esto: ¿qué es el Service Container?
El service container de Laravel es como una caja que recuerda “si se necesita esta clase, así es como se construye”. Por ejemplo, si haces type-hint de una clase en el constructor de un controlador, Laravel la resolverá automáticamente y te la pasará.
class OrderController extends Controller
{
public function __construct(
private OrderService $orderService
) {}
}
En este punto, Laravel crea e inyecta OrderService. Si OrderService a su vez depende de otras clases, Laravel seguirá esas dependencias y las resolverá también. A esto lo llamamos “dependency injection”.
El punto importante es que el controlador no necesita saber cómo crearla. Solo necesita declarar qué necesita. Una vez que escribes código de esta manera, se vuelve mucho más fácil intercambiar implementaciones más adelante o reemplazarlas por Fakes en pruebas.
3. ¿Por qué reducir new facilita el mantenimiento?
Las personas principiantes suelen sentirse tentadas a escribir código como este:
public function store(Request $request)
{
$mailer = new WelcomeMailer();
$mailer->send($request->email);
}
Esto funciona. Pero este estilo tiene debilidades.
- Es difícil reemplazar
WelcomeMailerpor otra implementación - Es difícil sustituirlo por un fake en pruebas
- Las dependencias quedan enterradas dentro del código, haciendo más difícil inspeccionarlas
- En cuanto aparecen configuraciones o ramificaciones según el entorno, la complejidad aumenta rápidamente
Con dependency injection, cambia a esto:
class RegisterController extends Controller
{
public function __construct(
private WelcomeMailer $welcomeMailer
) {}
public function store(Request $request)
{
$this->welcomeMailer->send($request->email);
}
}
Solo con esto ya queda mucho más claro qué está usando la clase. Y cuando más adelante quieras reemplazar el contenido de WelcomeMailer, hay muchas probabilidades de que puedas hacerlo sin tocar el controlador.
4. Patrón básico de Dependency Injection: haz de la inyección por constructor tu primera opción
Laravel ofrece varias formas de inyectar dependencias, pero la primera que deberías aprender es la inyección por constructor. La razón es simple: las dependencias quedan declaradas de forma clara en la entrada de la clase.
class InvoiceController extends Controller
{
public function __construct(
private InvoiceService $invoiceService,
private ExportService $exportService
) {}
public function store(StoreInvoiceRequest $request)
{
$invoice = $this->invoiceService->create($request->validated());
return redirect()
->route('invoices.show', $invoice)
->with('status', 'Invoice created.');
}
}
La ventaja de este estilo es que, en cuanto miras la clase, puedes ver enseguida “de qué depende este controlador”. Cuanto más grande es el proyecto, más valiosa se vuelve esta explicitud.
Por otro lado, si metes absolutamente todo en el constructor, incluso dependencias que solo se usan en una única acción, entonces puede volverse demasiado cargado y difícil de leer. Ahí es donde la siguiente guía resulta útil:
- Cosas que se usan en toda la clase: inyección por constructor
- Cosas que se usan solo en un método específico: inyección por método
- Requests de Laravel y Route Model Binding: la inyección por método suele resultar más natural
5. Inyección por método: pasa dependencias de forma ligera cuando solo se usan en una acción específica
public function export(
Request $request,
CustomerExportService $exportService
) {
$job = $exportService->dispatch($request->user());
return back()->with('status', 'Export started.');
}
Así, cuando una dependencia solo se usa en una acción concreta, recibirla como argumento del método mantiene más limpia la clase en su conjunto. En comparación con la inyección por constructor, a veces puede parecer que las dependencias se vuelven más dispersas, pero si la intención está clara —“esto solo se usa en este método”— sigue siendo muy legible.
Las personas principiantes suelen querer ponerlo todo en el constructor, pero ayuda detenerse y preguntarse: “¿esto se necesita en toda la clase o solo en este método?”
6. ¿Por qué introducir una capa de servicio? Mantén los controladores como el “punto de entrada HTTP”
En Laravel, puedes ponerlo todo en un controlador y seguirá funcionando. Pero en cuanto un controlador empieza a asumir todas las siguientes responsabilidades, rápidamente se vuelve doloroso:
- Recibir entradas
- Validación
- Persistencia en base de datos
- Llamadas a APIs externas
- Envío de notificaciones
- Logging
- Manejo de excepciones
- Redirecciones o respuestas JSON
Los controladores son más fáciles de leer cuando se mantienen cerca de su rol original como punto de entrada HTTP: “recibir la request, entregarla al proceso adecuado y devolver el resultado”. Por esa razón, es mejor mover la lógica de negocio a una capa de servicios o a clases Action.
6.1 Ejemplo: servicio de creación de pedidos
namespace App\Services;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function create(User $user, array $data): Order
{
return DB::transaction(function () use ($user, $data) {
$order = $user->orders()->create([
'total_amount' => $data['total_amount'],
'status' => 'pending',
'note' => $data['note'] ?? null,
]);
foreach ($data['items'] as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $item['price'],
]);
}
return $order;
});
}
}
6.2 Lado del controlador
class OrderController extends Controller
{
public function __construct(
private OrderService $orderService
) {}
public function store(StoreOrderRequest $request)
{
$order = $this->orderService->create(
$request->user(),
$request->validated()
);
return redirect()
->route('orders.show', $order)
->with('status', 'Your order has been received.');
}
}
Cuando se separa de esta forma, el controlador se vuelve más corto y la lógica de negocio es más fácil de probar de forma independiente.
7. Cómo usar de forma diferente Services y Actions: una forma de evitar que crezcan demasiado
Una vez que empiezas a introducir capas de servicio, tiende a aparecer otro problema: el servicio se convierte en una “clase que hace de todo”. Aquí es donde la idea de Actions resulta útil.
- Service: maneja un grupo de responsabilidades relacionadas dentro de un dominio (
OrderService,UserService, etc.) - Action: maneja un único propósito (
CreateOrderAction,SuspendUserAction, etc.)
Por ejemplo, si OrderService empieza a crecer con métodos como create, cancel, refund, export, notify y sync, poco a poco se vuelve más difícil de leer. En ese punto, dividir procesos de un solo propósito y muy usados en Actions puede dejar todo más limpio.
namespace App\Actions;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CreateOrderAction
{
public function execute(User $user, array $data): Order
{
return DB::transaction(function () use ($user, $data) {
$order = $user->orders()->create([
'total_amount' => $data['total_amount'],
'status' => 'pending',
]);
foreach ($data['items'] as $item) {
$order->items()->create($item);
}
return $order;
});
}
}
Con este nivel de granularidad, las pruebas son más fáciles de leer y el alcance de la responsabilidad cuando algo falla queda más claro. No necesitas convertirlo todo en una Action, pero es útil recordar esto como una forma de dividir servicios que han crecido demasiado.
8. Separación por interfaces: abstrae los procesos que probablemente querrás reemplazar
El verdadero valor de dependency injection se vuelve visible cuando separas los tipos de procesamiento que probablemente querrás sustituir más adelante. Por ejemplo:
- Pagos
- Envío de correos
- Envío de SMS
- Integraciones con APIs externas
- Almacenamiento de archivos
- Motores de búsqueda
- Generación de informes
Todos estos son elementos que quizás quieras cambiar a otro proveedor más adelante, o reemplazar con un Fake en pruebas. Ahí es donde depender de interfaces te da flexibilidad.
8.1 Ejemplo: notificación de facturación
namespace App\Contracts;
interface BillingNotifier
{
public function sendInvoiceReady(int $userId, int $invoiceId): void;
}
namespace App\Services;
use App\Contracts\BillingNotifier;
use App\Mail\InvoiceReadyMail;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
class MailBillingNotifier implements BillingNotifier
{
public function sendInvoiceReady(int $userId, int $invoiceId): void
{
$user = User::findOrFail($userId);
$invoice = Invoice::findOrFail($invoiceId);
Mail::to($user->email)->queue(new InvoiceReadyMail($invoice));
}
}
De esta forma, aunque más adelante quieras cambiar a notificaciones por Slack o a un servicio externo de notificaciones, quien consume esto solo necesita conocer BillingNotifier.
9. Registro en un ServiceProvider: cómo pensar sobre bind y singleton
Cuando usas interfaces, necesitas decirle a Laravel: “para este contrato, usa esta implementación”. Para eso sirve un ServiceProvider.
namespace App\Providers;
use App\Contracts\BillingNotifier;
use App\Services\MailBillingNotifier;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(BillingNotifier::class, MailBillingNotifier::class);
}
}
9.1 bind
El comportamiento básico es crear una nueva instancia cada vez que se resuelve. Para servicios normales sin estado, bind suele bastar como opción por defecto.
9.2 singleton
Esto es útil cuando quieres reutilizar la misma instancia durante toda una request. Sin embargo, si conviertes una clase con estado en singleton, puede convertirse fácilmente en fuente de efectos secundarios no deseados. Si estás empezando, es más seguro usarlo solo cuando la necesidad sea muy clara.
9.3 scoped
Esto es útil cuando quieres que la resolución quede ligada a una request o a una unidad del ciclo de vida, pero en la práctica, entender primero bind y singleton suele ser suficiente.
10. Evita la sobreabstracción: por qué es mejor no convertir todo en una interfaz
Una vez que aprendes dependency injection, puede que sientas la tentación de crear una interfaz para cada clase. Pero si exageras con esto, el código en realidad se vuelve más difícil de leer. La abstracción resulta más valiosa sobre todo en estas situaciones:
- Hay una alta probabilidad de que la implementación se sustituya
- Depende de un servicio externo
- Quieres convertirlo en un Fake en pruebas
- El propio contrato tiene significado
Por otro lado, si creas una interfaz cada vez para servicios simples que existen solo dentro del proyecto, terminas aumentando el número de archivos mientras dificultas seguir el código. Así que, en lugar de abstraer todo desde el principio, es más práctico comenzar con cosas donde “puedes imaginar claramente que esto se reemplazará en el futuro”.
11. El Container y las pruebas: facilita reemplazar cosas con Fakes
Una gran ventaja del service container es que se vuelve fácil sustituir cosas en pruebas. Por ejemplo, si quieres reemplazar la parte de notificación con un Fake, puedes registrar el reemplazo en el container.
11.1 Implementación Fake
namespace Tests\Fakes;
use App\Contracts\BillingNotifier;
class FakeBillingNotifier implements BillingNotifier
{
public array $sent = [];
public function sendInvoiceReady(int $userId, int $invoiceId): void
{
$this->sent[] = compact('userId', 'invoiceId');
}
}
11.2 Reemplazarlo en la prueba
public function test_invoice_ready_notification_is_dispatched()
{
$fake = new \Tests\Fakes\FakeBillingNotifier();
$this->app->instance(\App\Contracts\BillingNotifier::class, $fake);
$user = User::factory()->create();
$invoice = Invoice::factory()->create(['user_id' => $user->id]);
app(\App\Contracts\BillingNotifier::class)
->sendInvoiceReady($user->id, $invoice->id);
$this->assertCount(1, $fake->sent);
$this->assertSame($invoice->id, $fake->sent[0]['invoiceId']);
}
Cuando tu código no depende directamente de clases concretas, las pruebas se vuelven mucho más flexibles. También se hace más fácil simular cosas como fallos de APIs externas.
12. Excepciones y valores de retorno: organiza lo que la capa de servicio devuelve a la UI
Una vez que introduces una capa de servicio, se vuelve importante pensar en “qué se devuelve a la pantalla”. Una forma útil de pensarlo es esta:
- En éxito: devolver un resultado significativo, como un modelo o un DTO
- En fallo esperado: manejarlo como una excepción de negocio o una excepción de validación
- En fallo inesperado: lanzar una excepción y manejarla de forma centralizada en el Handler
Por ejemplo, para un fallo de negocio como stock insuficiente, devolver false suele ser menos útil que usar una excepción significativa o un objeto de resultado.
namespace App\Exceptions;
use RuntimeException;
class OutOfStockException extends RuntimeException
{
}
if ($product->stock < $qty) {
throw new OutOfStockException('Not enough stock available.');
}
Luego capturas esto en el controlador o en el Handler y devuelves un mensaje claro a la pantalla. Cuanto mejor estén separadas las responsabilidades, más fácil se vuelve mantener consistentes los mensajes de error en la UI.
13. Conexión con formularios y UI de notificaciones: el diseño backend afecta directamente la claridad de la pantalla
A primera vista, service containers y dependency injection pueden parecer muy alejados de la UI. Pero en realidad, una vez que el backend está organizado, los mensajes de la UI se vuelven mucho más estables.
Por ejemplo, si la lógica de creación de pedidos está escrita directamente dentro del controlador, las bifurcaciones de éxito / fallo tienden a variar de pantalla en pantalla. En cambio, cuando Actions y Services están bien organizados, los controladores pueden alinearse más fácilmente en torno a patrones como “esta notificación en caso de éxito” y “esta visualización de error para excepciones de negocio”.
try {
$order = $this->createOrderAction->execute(
$request->user(),
$request->validated()
);
return redirect()
->route('orders.show', $order)
->with('status', 'Your order has been received.');
} catch (OutOfStockException $e) {
return back()
->withInput()
->withErrors(['items' => 'Some items are out of stock.']);
}
Cuando estructuras las cosas así, el lado de la pantalla puede usar role="status" y role="alert" de manera más consistente. El diseño accesible de notificaciones encaja muy bien con responsabilidades backend bien separadas.
14. ¿Es necesario el patrón Repository? En Laravel, lo que importa es el “propósito”
Una discusión de diseño común en Laravel es el patrón Repository. La respuesta corta es: no necesitas convertir todo en un Repository. Eloquent ya es muy utilizable, y si fuerzas todo CRUD simple a pasar por una capa Repository, las cosas en realidad pueden volverse más complicadas.
Dicho esto, una separación tipo Repository puede ser útil en casos como estos:
- Quieres reutilizar condiciones de búsqueda complejas
- Existe la posibilidad de cambiar a un backend de almacenamiento no basado en base de datos
- Quieres organizar consultas agregadas o lógica de obtención transversal
- Quieres alejar ligeramente la dependencia de Eloquent de la capa de aplicación
En otras palabras, no deberías usar un Repository “porque es un patrón”. Es mejor usarlo “cuando haya una complejidad real que quieras organizar”. En etapa principiante, normalmente obtendrás más valor aprendiendo primero a separar con claridad Eloquent + Service / Action + Resource.
15. Una estructura mínima práctica: estas tres cosas bastan para empezar
No necesitas abstraerlo todo de golpe. Solo con ser consistente en estos tres puntos, tu código ya quedará mucho más limpio.
- Mantén los controladores delgados
- Mueve la lógica de negocio reutilizable a Services / Actions
- Crea interfaces solo para cosas que probablemente querrás reemplazar
Por ejemplo, una estructura como esta:
StoreOrderRequest: responsabilidad de entradaCreateOrderAction: responsabilidad de lógica de negocioBillingNotifier: contrato de dependencia externaOrderController: entrada y salida HTTPOrderResource: responsabilidad de formato API
Incluso separando las cosas solo hasta este punto, el código se vuelve mucho más fácil de entender. En lugar de aspirar a una arquitectura enorme desde el principio, es más práctico ir recortando un área problemática cada vez.
16. Errores comunes y cómo evitarlos
16.1 El controlador se vuelve más delgado, pero el Service se hace enorme
Como contramedida, divídelo en Actions de un solo propósito o divide Services por responsabilidad. Intenta evitar meterlo todo en algo como UserService.
16.2 Crear una interfaz para todo
Si abstraes incluso cosas que probablemente no se van a reemplazar, el número de archivos aumenta y se vuelve más difícil seguir el código. Empieza por dependencias externas o por cosas donde el contrato realmente importe.
16.3 Dispersar new por todas partes en lugar de usar el container
Esto dificulta futuros reemplazos y las pruebas. Convertir la inyección por constructor en un hábito suele ser la primera mejora más sencilla.
16.4 El Service empieza a encargarse también del formateo de la respuesta
Si mantienes la presentación HTML y API en Resources o ViewModels, las responsabilidades quedan más limpias.
16.5 Un diseño de excepciones vago provoca mensajes inconsistentes en pantalla
Si separas claramente los fallos esperados de los inesperados, las notificaciones de la UI se vuelven más estables.
17. Checklist (para entregar)
Diseño
- [ ] Los controladores se mantienen enfocados en entrada / salida HTTP
- [ ] La lógica de negocio reutilizable está separada en Services / Actions
- [ ] Las interfaces se introducen solo para cosas que deben poder reemplazarse
- [ ]
newno está disperso por controladores y servicios
Container
- [ ] El uso de
bind/singletonestá organizado con claridad - [ ] El registro de dependencias es explícito en
AppServiceProvidero similar - [ ] La estructura facilita intercambiar Fakes o Mocks durante las pruebas
Pruebas
- [ ] Los Actions / Services importantes tienen pruebas
- [ ] Las dependencias externas pueden reemplazarse con Fakes
- [ ] El comportamiento de las excepciones está cubierto por pruebas
Conexión con la UI
- [ ] Los mensajes de éxito / fallo no varían demasiado entre pantallas
- [ ] Los errores esperados se devuelven de una forma que las personas usuarias puedan entender
- [ ] Los flujos de retorno son consistentes de una manera que favorece
role="status"/role="alert"
18. Resumen
El service container y dependency injection de Laravel no son solo “diseño sofisticado”. Son mecanismos prácticos que mejoran la claridad del código, facilitan reemplazos y pruebas, y como resultado también facilitan las operaciones y la respuesta ante incidentes. Puede parecer difícil al principio, pero basta con empezar reduciendo new mediante la inyección por constructor y moviendo grandes procesos de negocio fuera de los controladores hacia Services o Actions. A partir de ahí, si introduces interfaces solo en las partes que probablemente querrás reemplazar, tu aplicación Laravel irá creciendo poco a poco hacia un diseño débilmente acoplado de una forma natural y manejable. No hace falta apresurarse: empieza organizando solo un proceso cada vez.

