Guía

Formularios y flujo POST

El flujo habitual desde un formulario GET, pasando por POST, CSRF y validación, hasta el redirect y el GET posterior.

Flujo completo de un formulario

Un flujo server-rendered habitual funciona así: GET renderiza el formulario, POST pasa por CsrfMiddleware y el controller lee y valida la entrada una sola vez. Si falla, devuelve el formulario con errores y valores originales; si tiene éxito, delega la escritura a un servicio de aplicación, guarda un flash message, hace redirect y termina en un GET posterior. Es el patrón Post/Redirect/Get. Continúa con Validación para las rules y con Middleware para más guards HTTP.

Rutas y protección CSRF

Mantén sencilla la ruta GET y asigna CsrfMiddleware a la ruta POST que cambia estado. El view renderiza el token mediante el helper CSRF integrado, de modo que request y view usan el mismo mecanismo del framework.

example.php
<?php

use App\Controllers\ContactController;
use Lemonade\Framework\Routing\Router;
use Lemonade\Framework\Security\Csrf\CsrfMiddleware;

return static function (Router $router): void {
    $router->getNamed('contact.form', '/contact', ContactController::class . '@form');

    $router
        ->postNamed('contact.submit', '/contact', ContactController::class . '@submit')
        ->middleware(CsrfMiddleware::class);
};

Acción GET, acción POST, validación y redirect

La acción GET prepara los valores iniciales y renderiza la página. La acción POST lee la entrada mediante helpers del controller, la valida una sola vez, devuelve el mismo formulario con HTTP 422 si falla y, si tiene éxito, llama a un servicio de aplicación para la escritura, configura un flash message y redirige.

example.php
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Services\ContactSubmissionService;
use Lemonade\Framework\Core\AbstractController;
use Lemonade\Framework\Validation\ValidationSchema;
use Psr\Http\Message\ResponseInterface;

final class ContactController extends AbstractController
{
    public function __construct(
        private readonly ContactSubmissionService $contactService,
    ) {}

    public function form(): ResponseInterface
    {
        return $this->renderForm(
            values: [
                'name' => '',
                'email' => '',
                'message' => '',
            ],
            errors: [],
        );
    }

    public function submit(): ResponseInterface
    {
        $values = [
            'name' => $this->inputString('name'),
            'email' => $this->inputString('email'),
            'message' => $this->inputString('message'),
        ];

        $schema = ValidationSchema::create()
            ->field('name', 'Name')
                ->required()
                ->maxLength(100)
            ->end()
            ->field('email', 'E-mail')
                ->required()
                ->email()
            ->end()
            ->field('message', 'Message')
                ->required()
                ->maxLength(2000)
            ->end();

        $result = $this->validator()->validate($values, $schema);

        if (!$result->isValid()) {
            return $this->renderForm(
                values: $values,
                errors: $result->errors(),
                status: 422,
            );
        }

        $this->contactService->store($result->validated());
        $this->flash()->set('success', 'Your message has been sent.');

        return $this->redirect($this->url()->route('contact.form'));
    }

    /**
     * @param array{name:string,email:string,message:string} $values
     * @param array<string, string> $errors
     */
    private function renderForm(array $values, array $errors, int $status = 200): ResponseInterface
    {
        return $this->html(
            $this->view()->template('layouts.app', 'pages.contact-form', [
                'values' => $values,
                'errors' => $errors,
            ]),
            $status,
        );
    }
}

Renderiza el formulario con CSRF, valores y errores

El form view renderiza csrfField(), devuelve los valores enviados a los inputs y lee el success flash message en el GET redirigido. Pasa values y errors explícitamente desde el controller para que el view no dependa de helpers ocultos de la aplicación demo.

example.php
<?php

/**
 * @var \Lemonade\Framework\View\View $this
 * @var \Lemonade\Framework\View\ViewHelpers $helpers
 * @var \Lemonade\Framework\View\RequestViewHelpers $requestHelpers
 * @var array<string, string> $errors
 * @var array{name:string,email:string,message:string} $values
 */
?>
<?php if ($success = $requestHelpers->flash('success')): ?>
    <div class="alert alert-success"><?= e((string) $success) ?></div>
<?php endif; ?>

<form method="post" action="<?= e($helpers->url('contact.submit')) ?>">
    <?= $helpers->csrfField() ?>

    <input name="name" value="<?= e($values['name']) ?>">
    <input name="email" value="<?= e($values['email']) ?>">
    <textarea name="message"><?= e($values['message']) ?></textarea>

    <?php if (isset($errors['message'])): ?>
        <p><?= e($errors['message']) ?></p>
    <?php endif; ?>

    <button type="submit">Send</button>
</form>

Mantén la escritura fuera del controller

El controller debe encargarse de la orquestación HTTP. La escritura puede vivir en un pequeño servicio de aplicación cuando deja de ser un simple wiring del request con el framework. Así el controller se mantiene legible sin convertir las capas service o repository en obligatorias.

example.php
<?php

declare(strict_types=1);

namespace App\Services;

use Lemonade\Framework\Database\Database;

final class ContactSubmissionService
{
    public function __construct(
        private readonly Database $db,
    ) {}

    /**
     * @param array{name:string,email:string,message:string} $data
     */
    public function store(array $data): void
    {
        $this->db->table('contact_messages')->insert($data);
    }
}