¿Por qué usar Service Providers?
Laravel es un framework que nos facilita el desarrollo de aplicaciones web.
Usar Laravel es muy sencillo. Sin embargo, si queremos entender realmente cómo funciona o cuál es la filosofía que sigue, es realmente importante comprender 2 conceptos (service container y service provider).
  • Laravel como framework usa un "contenedor de servicios" y unos "proveedores de servicios" siempre que necesita iniciar una instancia de una aplicación Laravel.
  • No solo nuestras aplicaciones, sino también los servicios que conforman el núcleo de Laravel, se inician gracias a los service providers.
  • Los service providers como su mismo nombre lo indica, se encargan de toda la configuración necesaria antes de empezar a usar un servicio. Dependiendo del servicio que se va a iniciar, el proveedor se encarga de crear nuevas instancias y posiblemente nuevas relaciones con otros servicios (definir parámetros, oyentes de eventos, middlewares, rutas).
La creación de nuevos paquetes para el framework Laravel requiere conocer bien estos conceptos.
¿Cómo usar Service Providers?
Más adelante, en este artículo, vamos a ver cómo crear nuestro propio service provider.
De momento, un breve adelanto:
  • El archivo config/app.php presente en los proyectos Laravel, contiene un arreglo llamado providers.
  • Este arreglo es un listado de todos los service providers que serán cargados en nuestra aplicación. Como ves, cada service provider es una clase.
  • No todos los proveedores serán cargados en cada petición que nuestra aplicación resuelva. Muchos de ellos son cargados únicamente cuando se requieren.
¿Cuándo usar Service Providers?
Cuando desarrollamos una aplicación muy simple, tenemos secciones muy simples donde basta con validar y registrar datos. En estos casos no es necesario usar Service Providers.
Sin embargo, hay aplicaciones que van más allá de registrar datos. Por ejemplo:
  • Cuando debemos subir y procesar documentos de texto (.txt, .pdf, .doc, .docx).
  • Cuando debemos enviar notificaciones, de distintos tipos (vía email, sms, push notifications).
  • Cuando debemos subir y procesar imágenes (distintos tamaños, diversas transformaciones, variedad de formatos).
En estos casos, tenemos muchas formas de hacer nuestra implementación.
  • Podemos usar como base un paquete ya existente.
  • Podemos escribir nuestra propia lógica.
  • O podemos combinar el uso de distintos paquetes y nuestros propios algoritmos según se requiera.
Entonces, dependiendo de la magnitud del problema a resolver, hemos de optar por "crear un servicio que resuelva fácilmente nuestras necesidades".
  • Si la solución que hemos encontrado es breve, y se usa en un único lugar, podemos optar por crear un método (en la clase donde se necesite y eso será suficiente).
  • Si la solución es muy breve, no requiere de una configuración inicial, y debe usarse en distintos lugares, podemos definir un helper (un método disponible en todo nuestro proyecto).
  • Sin embargo, si la solución requiere de una configuración inicial (depende de otras clases, y requiere parámetros específicos), lo más recomendable es crear un service provider (un proveedor, para que se encargue de la configuración de este servicio, y nosotros simplemente lo usemos).

¿Qué necesitamos?

En este artículo:
  • Primero vamos a ver (de forma general), qué es el service container de Laravel.
  • Cómo crear un service provider en nuestro proyecto Laravel.
  • Y finalmente, cómo registrar y usar este service provider.
A modo de ejemplo, el servicio que nos interesa, va a estar relacionado con lo siguiente:
  • "Estoy" desarrollando una aplicación para postular a ofertas de empleo.
  • Los postulantes pueden subir sus CV al momento de postular si no lo hicieron antes.
  • Pero también pueden actualizar sus CV desde su perfil en cualquier momento.
  • Y los administradores pueden subir CVs en lote sin la necesidad de asociar estos a un usuario determinado.
"Hipotéticamente" ya he desarrollado la funcionalidad para subir archivos CV en lote.
  • Subir hojas de vida no consiste en almacenar archivos de forma local.
  • El archivo se debe subir a un bucket de S3 y su ubicación depende del formato.
  • Se debe extraer el texto del documento e indexarlo a una base de datos para agilizar el proceso de búsqueda (esto también depende del formato del documento).
Lo bueno es que ya funciona para la subida en lote. Pero, repetir código, a fin de tener la misma funcionalidad en distintas secciones de la aplicación, no es adecuado.
  • Entonces vamos a crear un proveedor de servicios que inicialice por nosotros el servicio de nuestro interés.
  • Un método extenso dentro de un controlador, que hace uso de distintas clases pasará a ser un servicio de fácil uso.
Si estás siguiendo este artículo y ya has pensado en cómo aplicar estos conceptos. Mi sugerencia es la siguiente:
  • Primero refactoriza el código que ya tienes, y asegúrate de no perder ninguna funcionalidad.
  • Luego usa el servicio en otras secciones, o desarrolla nuevas características.
No es imposible implementar nuevas características mientras se refactoriza. Pero no es recomendable.

Creando nuestro service provider

Laravel, como siempre, nos facilita las cosas:
php artisan make:provider CvUploaderServiceProvider
  • Este comando creará un archivo CvUploaderServiceProvider.php con la estructura básica que un service provider debe tener.
  • El archivo se creará en la carpeta app/Providers.
Si abres el archivo te encontrarás con una clase del mismo nombre, que contiene 2 métodos: register y boot.
En breve vamos a ver la diferencia entre estos 2 métodos. Pero antes debemos registrar nuestro proveedor ante Laravel.
Para ello vamos al archivo config/app.php y dentro del arreglo providers añadimos el que acabamos de crear:
'providers' => [

/*
* Laravel Framework Service Providers...
*/

Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
// [...]
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,

/*
* Package Service Providers...
*/

Laravel\Tinker\TinkerServiceProvider::class,

/*
* Application Service Providers...
*/

App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\CvUploaderServiceProvider::class,

],

El método register

Como su mismo nombre lo indica, nos permite registrar. ¿Pero registrar qué y sobre qué?
  • Nos permite registrar "bindings" (esto es, terminología que usa Laravel en su documentación; en español sería enlaces).
  • Y estos bindings se registran sobre el service container.
¿Qué representan estos bindings? ¿Qué enlazan?
Registrar un binding en el service container de Laravel es decirle al contenedor cómo instanciar un objeto en particular.
  • El uso más básico es instanciar un objeto de una clase que no tiene ninguna dependencia, que no requiere de ningún parámetro.
  • Sin embargo también es posible decirle al contenedor de Laravel cosas como "cuando se requiera un objeto de esta interfaz, crea una instancia de esta clase con estos parámetros".
  • E inclusive, podemos decirle a Laravel que use una clase determinada cuando la instancia se requiera en un controlador específico, pero que use otra clase cuando la instancia se necesite en otros contextos.
El service container de Laravel nos permite registrar distintos tipos de bindings.
Recapitulando, respecto al método register: es un método que le permite a nuestro ServiceProvider registrar bindings en el contenedor de Laravel.

Registrando nuestro servicio en el service container

Esto es posible definiendo un binding en el método register de nuestro service provider.
Pero para esto necesitamos: haber definido la clase que queremos instanciar (una clase que represente a nuestro servicio).
En este caso voy a crear una clase llamada CvHandler. Recuerda que puedes crear esta clase donde mejor te parezca.
La clase que he creado está disponible bajo el namespace Tawa\Services (siendo Tawa el nombre de la aplicación que estoy desarrollando, y Services la carpeta que contiene todos los servicios creados para esta aplicación).
Entonces hemos de registrar el binding de la siguiente manera (dentro del método register):
$this->app->bind(CvHandler::class, function ($app) {
return new CvHandler();
});
Esta es una de las tantas formas de registrar un binding.
¿Por qué es necesario un closure? (la función que aparece como segundo parámetro).
Buena observación. En este caso no es necesario. Pero cuando una clase tiene dependencia respecto a otras, es justamente aquí donde se crea la instancia con la configuración requerida; y es éste uno de los mayores beneficios de usar un service provider.
Veamos un ejemplo, de una clase que requiere de ciertos parámetros para su correcto funcionamiento en nuestra aplicación.
$unaInstancia = new ClaseA(new ClaseB(config('secret_key')), new ClaseC(new ClaseX(), new ClaseY()), new ClaseD('pym', 7));
En este ejemplo:
  • Nuestro proyecto necesita usar un objeto de la clase ClaseA.
  • Pero, necesitamos una instancia de ClaseA con ciertos parámetros (bien específicos).
  • Se requiere de una instancia de ClaseB (que depende de una variable de configuración).
  • Se requiere de una instancia de ClaseC (que depende a su vez de otras 2 clases).
  • Y finalmente se requiere de un objeto de ClaseD, instanciado con 2 parámetros en específico.
Usar este código en un controlador y/o en todas las clases que necesitemos una instancia de ClaseA, no es bueno.
Es aquí donde un proveedor de servicios nos proveería del servicio que brinda ClaseA.
Para ello habría que registrar un binding del siguiente modo:
$this->app->singleton(ClaseA::class, function ($app) {
return new ClaseA(new ClaseB(config('secret_key')), new ClaseC(new ClaseX(), new ClaseY()), new ClaseD('pym', 7));
});
Si eres observador, habrás notado que en este caso hemos usado singleton en vez de bind. Es otra forma de crear un binding. En este caso haciendo uso del patrón de diseño Singleton.
En vez de llamar al closure cada vez que se necesite una instancia, tendríamos una misma instancia compartida en todo nuestro proyecto.
Volviendo a nuestro ejemplo inicial.
  • Si nuestro servicio no requiere de ninguna configuración, y tampoco se asocia con ninguna interfaz, entonces no es necesario registrarlo en el service container (la inyección de dependencias funcionará con esta clase incluso sin darle instrucciones a Laravel).
  • Sin embargo, es recomendable asociar una interfaz a nuestros servicios. De esta forma, será posible usar otra clase (con una implementación distinta) bajo otras circunstancias.
    Por ejemplo, podemos usar una implementación distinta para nuestro entorno local y nuestro entorno de producción. También podemos usar una implementación distinta para la ejecución de pruebas automatizadas.

Usando nuestro servicio

Como ya comenté antes, si nuestra clase no tiene dependencias y no implementa ninguna interfaz, no es necesario crear un binding.
Su uso estaría disponible sin ningún paso adicional:
class UploadController extends Controller
{

public function store(CvHandler $cvHandler)
{
// aquí es posible usar $cvHandler para procesar las hojas de vida
}

}
Pero, si queremos asociar una interfaz a nuestra clase (que es lo más recomendable), debemos registrar un binding en el método register:
$this->app->bind(CvHandlerInterface::class, CvHandler::class);
De esta forma podemos decir que:
Un service provider ha registrado nuestro servicio en el service container, y podemos usar nuestro servicio desde donde nos plazca (gracias a la inyección de dependencias).
Y si usamos una interfaz, el uso sería del siguiente modo:
public function store(CvHandlerInterface $cvHandler)
{
// aquí es posible usar $cvHandler para procesar las hojas de vida
}
Sin importar cómo se resuelva esta inyección de dependencias, $cvHandler debe ser capaz de usar los métodos que dicta la interfaz.
Para el ejemplo que estábamos viendo, como mínimo la interfaz exigiría el siguiente método:
<?php namespace Tawa\Interfaces;

use App\User;

interface CvHandlerInterface
{
public function uploadCV($uploader, $owner, UploadedFile $file);
}
Y todas nuestras implementaciones estarían en la obligación de definir este método:
class CvHandler implements CvHandlerInterface
{
public function uploadCV($uploader, $owner, UploadedFile $cv)
{
// TODO: Implementar el método uploadCV()
}
}

Ejemplo de refactorización

Este ejemplo no tiene relación directa con la comprensión de los conceptos antes mencionados. Sin embargo es interesante ver cómo cambia la organización del código.
El siguiente método store se usa para subir un CV a S3 y guardar su contenido en la base de datos.
Antes.
public function store(Request $request)
{
$rules = [
'cv' => 'required|mimes:pdf,doc,docx|max:10000'
];
$this->validate($request, $rules);

$cv = $request->file('cv');
$extension = $cv->getClientOriginalExtension();

$uniqueId = uniqid(); // current time considering microseconds
$fullPath = "cv/anonymous/$uniqueId.$extension";

$fileContents = file_get_contents($cv);
Storage::disk('s3')->put($fullPath, $fileContents);
$successfulUpload = Storage::disk('s3')->exists($fullPath);

$saved = false;
if ($successfulUpload) {
// parse the CV document
if ($extension == 'pdf') {
$parser = new Parser();
$pdf = $parser->parseFile($cv);
$text = $pdf->getText();
} else { // doc, docx
$filename = $cv->path();
$mimeType = File::mimeType($cv);
$text = DocumentParser::parseFromFile($filename, $mimeType);
}

// store the resume in the db
$resume = new Resume();
$resume->owner_id = null; // anonymous cv owner
$resume->uploader_id = auth()->user()->id;
$resume->file_name = "$uniqueId.$extension"; // with extension
$resume->content = $text;
$saved = $resume->save();
}

if ($saved)
return response()->json('success', 200);
// else
return response()->json('error', 500);
}
Aquí tenemos muchas cosas en juego:
  • Se usa el método file de la clase Request de Laravel, para obtener un objeto con información del archivo subido
  • Se obtiene la extensión del archivo
  • Se genera un id único basado en la hora actual del sistema
  • Se genera un nombre de archivo usando el id único pero conservando la extensión
  • Se sube el archivo a s3
  • Si la subida fue exitosa y la extensión es .pdf se usa una instancia de PdfParser\Parser para obtener el texto del archivo
  • Si la extensión no es .pdf se asume que es .doc o .docx, y se usa la clase DocumentParser (es una clase que he definido con métodos estáticos, pero vamos a cambiar esto, porque se debe evitar el uso de métodos estáticos siempre que sea posible, por diversas razones).
  • Por último registramos el texto extraído del documento en la BD, asociando el registro con el usuario que ha subido el CV y el usuario al que le pertenece
Luego de mover la lógica a CvHandler el método queda de la siguiente manera:
Después.
public function store(Request $request, CvHandlerInterface $cvHandler)
{
$rules = [
'cv' => 'required|mimes:pdf,doc,docx|max:10000'
];
$this->validate($request, $rules);

$cv = $request->file('cv');
$saved = $cvHandler->uploadCV(auth()->id(), null, $cv);

if ($saved)
return response()->json('success', 200);
// else
return response()->json('error', 500);
}
El método uploadCV se está llamando con un 2do parámetro que es null. Esto es así porque el CV no se asocia con ningún usuario.
En la subida de archivos en lote, un administrador sube CVs sin asociarlos a usuarios postulantes.
Siguiendo esta idea, CvHandler quedaría del siguiente modo y podría usarse desde distintos lugares de la aplicación:
class CvHandler implements CvHandlerInterface
{
protected $storage;
protected $pdfParser;
protected $docParser;

public function __construct(Storage $storage, Parser $pdfParser, DocumentParser $docParser)
{
$this->storage = $storage;
$this->pdfParser = $pdfParser;
$this->docParser = $docParser;
}

private function getUniqueFileName(UploadedFile $cv)
{
$uniqueId = uniqid();
$extension = $cv->getClientOriginalExtension();
return "$uniqueId.$extension";
}

private function uploadFile($fullPath, UploadedFile $cv)
{
$fileContents = file_get_contents($cv);
$this->storage->disk('s3')->put($fullPath, $fileContents);
return $this->storage->disk('s3')->exists($fullPath);
}

private function getTextFromDocument(UploadedFile $cv)
{