Guide

Forms & POST Workflow

The common flow from a GET form through POST, CSRF, and validation to a redirect and follow-up GET.

A complete form flow

A typical server-rendered flow works like this: GET renders the form, POST passes through CsrfMiddleware, and the controller reads and validates the input once. On failure it returns the form with errors and original values; on success it delegates the write to an application service, stores a flash message, redirects, and ends with a follow-up GET. This is the Post/Redirect/Get pattern. Continue to Validation for a deeper look at rules and to Middleware for additional HTTP guards.

Routes and CSRF protection

Keep the GET route simple and attach CsrfMiddleware to the state-changing POST route. The view renders the token through the built-in CSRF helper, so the request and view use the same framework mechanism.

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);
};

GET action, POST action, validation, and redirect

The GET action prepares default values and renders the page. The POST action reads input through controller helpers, validates it once, returns the same form with HTTP 422 on failure, and on success calls an application service for the write, sets a flash message, and redirects.

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,
        );
    }
}

Render the form with CSRF, values, and errors

The form view renders csrfField(), puts submitted values back into inputs, and reads the success flash message on the redirected GET. Pass values and errors explicitly from the controller so the view does not depend on hidden demo-application helpers.

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>

Keep writes out of the controller

The controller should own HTTP orchestration. The write itself can live in a small application service once it is more than simple request wiring. This keeps the controller readable without making service or repository layers mandatory.

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);
    }
}