Reutilizando patrones de Laravel para formularios de Livewire
Siempre me gustó mucho Livewire, pero desde el inicio algo no me cuadró: su manejo de formularios
Aunque Livewire tiene su propia solución para manejar formularios con AJAX, se siente muy desconectado del ecosistema de Laravel, porque claro, Livewire no procesa el formulario con una solicitud HTTP como un Controlador, pero siempre me pareció extraño duplicar reglas de validación en el Componente cuando ya se tiene escrita esa lógica en un custom Request
En Laravel comúnmente usamos un patrón de separación de responsabilidades para manejo de formularios, compuesto por
// Request ➜ Controller ➜ Action
Por ejemplo ProductController@store
como hub central de la lógica de negocio, pero el proceso de validación del formulario aparte en ProductStoreRequest
y las operaciónes relacionadas a la base de datos en ProductStoreAction
// app/Http/Requests/ProductStoreRequest.php
class ProductStoreRequest extends FormRequest
{
public function rules()
{
return [
'title' => 'required',
'price' => 'required',
'image' => 'required|file|max:10240',
];
}
}
// app/Http/Controllers/ProductController.php
class ProductController extends Controller
{
public function store(ProductRequest $request, ProductAction $action)
{
$product = $action->handle($request);
...
}
}
// app/Actions/ProductStoreAction.php
class ProductStoreAction
{
public function handle($request)
{
$product = new Product();
$product->fill($request->toArray());
$product->save();
}
}
Para no perder éste patrón de separación de responsabiliades, me puse a experimentar con maneras de replicar éste flujo directamente en el Componente Livewire, tratándolo como un equivalente del Controlador
Normalmente un Componente Livewire con un formulario se ve así
// app/Livewire/ProductForm.php
class ProductForm extends Component
{
public $form;
protected function rules()
{
return [
'form.title' => 'required',
'form.price' => 'required',
'form.image' => 'required|file|max:10240',
];
}
public function submit()
{
$this->validate();
$product = new Product();
$product->fill($this->form);
$product->save();
$this->reset();
}
}
Que cómo podemos ver, cuenta con sus propios métodos de validación rules()
y validate()
, pero éstos repiten los procesos de establecer reglas (que ya tenemos en ProductStoreRequest
) y almacenar los datos (que ya tenemos en ProductStoreAction
)
Mi solución consiste en reemplazar la lógica de creación y validación que ocurriría en el Componente con la reutilización de ProductStoreRequest
y ProductStoreAction
Para hacerlo, agregué un método estático build()
en ProductStoreRequest
, que construye manualmente un $request
a partir del parámetro $form
(que proviene del Componente Livewire) y lo valida ahí mismo aplicando sus mismas rules()
// app/Http/Requests/ProductStoreRequest.php
class ProductStoreRequest extends FormRequest
{
public function rules()
{
return [
'title' => 'required',
'price' => 'required',
'image' => 'required|file|max:10240',
];
}
public static function build(array $form): ProductStoreRequest
{
$request = new self();
$request->merge($this->form);
if ($this->form['image']) {
$request->files->set('image', UploadedFile::createFromBase($this->form['image']));
}
$validator = Validator::make($request->all() + $request->files->all(), $request->rules());
if ($validator->fails()) {
throw new ValidationException($validator);
}
return $request;
}
}
Y en el Componente llamo al método ProductStoreRequest::build($this->form)
, en lugar de usar los métodos rules()
y validate()
del Componente Livewire
En caso de que la validación falle (revisando explícitamente que el error sea de tipo ValidationException
), seteamos manualmente el error bag del Componente (que es lo que hace el método validate()
del Componente, originalmente)
Y en caso de que se construya exitosamente el $request
, lo pasamos al ProductStoreAction
, justo como ya lo hacemos en el Controlador
// app/Livewire/ProductForm.php
use App\Actions\ProductStoreAction;
use App\Http\Requests\ProductStoreRequest;
use Illuminate\Validation\ValidationException;
class ProductForm extends Component
{
public $form;
public function submit()
{
try {
$request = ProductStoreRequest::build($this->form);
} catch (Exception $e) {
if ($e instanceof ValidationException) {
foreach ($e->errors() as $key => $messages) {
$errors["form.$key"] = $messages[0];
}
$this->setErrorBag($errors);
}
$this->loading = false;
return false;
}
$action = new ProductStoreAction();
$action->handle($request);
}
}
// You may not like it, but this is what peak performance looks like
La solicitud HTTP nunca se realiza realmente, pero al obtener un $request
pre-validado, podemos replicar el mismo flujo existente del Controlador
Porqué no simplemente repetir la logica en cada lugar? Bueno, porque en mi casa me enseñaron que repetir código es un pecado!