Skip to content

This chapter needs extra packages

composer require naf/form naf/view naf/session naf/mail

A contact form

Start from Your first application, then run composer require naf/mail. The starter already supplies the other packages listed above. Each titled block is a complete file; create missing directories and replace the tutorial's corresponding files. If you combine recipes, merge their routes and service bindings instead of discarding the existing ones.

This local exercise writes messages to storage/mail/ and shows a thank-you after redirecting. It works without a mail server and sends nothing externally. For automated tests that only need an in-memory list, see the dummy transport. Handling a POST request explains the underlying request flow.

A local mail outbox

mkdir -p app/Mail storage/mail
app/Mail/FileTransport.php
<?php

namespace App\Mail;

use Naf\Mail\Core\TransportInterface;
use Naf\Mail\Models\Mail;

final class FileTransport implements TransportInterface
{
    public function sendMail(Mail $mail): bool
    {
        $data = json_encode([
            'from' => $mail->getFrom(), 'to' => $mail->getRecipients(),
            'replyTo' => $mail->getReplyTo(), 'subject' => $mail->getSubject(),
            'content' => $mail->getContent(), 'isHtml' => $mail->isHtml(),
        ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
        $file = BASE_PATH . '/storage/mail/' . bin2hex(random_bytes(12)) . '.json';
        return file_put_contents($file, $data, LOCK_EX) !== false;
    }
}
bootstrap.php
<?php

define('BASE_PATH', __DIR__);
require __DIR__ . '/vendor/autoload.php';

use App\Mail\FileTransport;
use Naf\Mail\Core\Mailer;
use Naf\Core\Event;
use Psr\Http\Message\ResponseInterface;
use function Naf\{app, event, request};

app()->container()->set(Mailer::class, static fn() => new Mailer(new FileTransport()));
// naf/framework 0.2.1 redirects carry HTTP/2; PHP's local server needs HTTP/1.x.
event()->listen(Event::RESPONSE_HEADER, static fn(ResponseInterface $response) =>
    $response->withProtocolVersion(request()->getProtocolVersion()));

app()->run();

The routes

app/routes.php
<?php
use App\Controllers\HomeController;
use App\Controllers\ContactController;
use function Naf\route;

route()->add('GET', '/', [HomeController::class, 'index'], 'home');
route()->add('GET', '/hello/{name}', [HomeController::class, 'hello'], 'hello');
route()->add('GET',  '/contact', [ContactController::class, 'show'],   'contact');
route()->add('POST', '/contact', [ContactController::class, 'submit'], 'contact.submit');

The controller

app/Controllers/ContactController.php
<?php

namespace App\Controllers;

use Psr\Http\Message\ResponseInterface;
use function Naf\Form\validator;
use function Naf\Mail\mail;
use function Naf\Mail\mailer;
use function Naf\Session\session;
use function Naf\View\render;
use function Naf\{abort, param};
use function Naf\redirect;
use function Naf\route;

final class ContactController
{
    public function show(): ResponseInterface
    {
        return render('contact', ['check' => validator()]);
    }

    public function submit(): ResponseInterface
    {
        foreach (['name', 'email', 'message'] as $field) {
            if (!is_string(param()->get($field, ''))) {
                abort(400, 'Form fields must be strings.');
            }
        }

        $check = validator()->validate(param()->all(), [
            'name'    => 'required|max:80',
            'email'   => 'required|email',
            'message' => 'required|min:10|max:2000',
        ]);

        if (!$check->isValid()) {
            // Back to the form. What was typed is still in the request, so
            // memory() finds it — see the template below.
            return render('contact', ['check' => $check])->withStatus(422);
        }

        $mail = mail()
            ->setFrom('website@example.com')
            ->addTo('office@example.com')
            ->setReplyTo(param()->get('email'))
            ->setSubject('New contact message')
            ->setContent('From: ' . param()->get('name') . "\n\n" . param()->get('message'), false);

        if (!mailer()->send($mail)) {
            throw new \RuntimeException('Could not write the local mail preview.');
        }

        session()->flash('notice', 'Thank you — we will get back to you.');

        return redirect(route('contact'));
    }
}

Three things in that method are deliberate.

setFrom() is your own address, not the visitor's. A message claiming to come from an address you do not control is what every receiving mail server treats as forgery, and it will land your mail in a spam folder or nowhere at all. The visitor's address belongs in setReplyTo(), which is what hitting Reply should use anyway.

The body is plain text. Passing false to setContent() avoids interpreting submitted text as HTML. The subject is fixed; visitor text belongs in the body.

The success path redirects. The thank-you lives in a flash message, which survives exactly one request — the redirect's — and is gone on the next reload.

The template

app/views/contact.phtml
<?php
use function Naf\Form\csrf;
use function Naf\Form\error;
use function Naf\Form\error_class;
use function Naf\Form\memory;
use function Naf\View\s;
use function Naf\Session\session;
use function Naf\route;
?>

<?php if ($notice = session()->getFlash('notice')): ?>
    <p class="notice"><?= s($notice) ?></p>
<?php endif; ?>

<form action="<?= route('contact') ?>" method="post">

    <label for="name">Your name</label>
    <input id="name" type="text" name="name"
           class="<?= error_class('name', $check) ?>"
           value="<?= s(memory('name') ?? '') ?>">
    <?= error('name', $check) ?>

    <label for="email">Your email address</label>
    <input id="email" type="email" name="email"
           class="<?= error_class('email', $check) ?>"
           value="<?= s(memory('email') ?? '') ?>">
    <?= error('email', $check) ?>

    <label for="message">Message</label>
    <textarea id="message" name="message" rows="8"
              class="<?= error_class('message', $check) ?>"><?= s(memory('message') ?? '') ?></textarea>
    <?= error('message', $check) ?>

    <input type="hidden" name="_csrf" value="<?= csrf()->generate() ?>">

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

memory() reads what was submitted, so a rejected form comes back filled in rather than blank — the difference between fixing one field and typing everything again. error() renders the message for one field, and error_class() gives you a class name to hang styling on. error() and error_class() take the validator the controller passed down, which is why show() passes an unused one: the same template serves both requests, and on the first there is simply nothing to report.

Try it

composer dump-autoload
php -S 127.0.0.1:8000 -t public

Open http://127.0.0.1:8000/contact. Invalid fields return HTTP 422 with errors and the submitted values. A valid name, email and message of at least ten characters redirects back with a thank-you. Inspect the new JSON file in storage/mail/; it contains the message. Reloading removes the flash message. A POST without a valid _csrf token returns 400.

The local outbox is application code for development, not a built-in NAF transport. To deliver real mail, replace its Mailer binding with a configured transport from Sending mail and use sender/recipient addresses you control. Check the boolean result before reporting success. Use a queue when delivery should happen outside the request.