Le formulaire de contact et l'e-mail
Temps 1 · séance 5 · ~45 min
Les slides du chapitre (PDF)PDF
- Enregistrer un message de contact validé par une Form Request.
- Écrire un
Mailableet l'envoyer tout de suite avecMail::to()->send(). - Lire l'e-mail dans le journal de Laravel, en local.
- Confirmer l'envoi au visiteur par un message flash.
- En bonus, freiner les robots avec une limite d'envois.
1. Le modèle et sa table
Un visiteur vous écrit. Son message doit arriver dans votre boîte e-mail, et rester dans la base si l'e-mail se perd. Le modèle ContactMessage du diagramme de classes du chapitre 1.05 le garde. Créez-le avec sa migration :
php artisan make:model ContactMessage -m
INFO Model [C:\…\portfolio\app\Models\ContactMessage.php] created successfully.
INFO Migration [C:\…\portfolio\database\migrations\2026_10_27_090831_create_contact_messages_table.php] created successfully.
Dans la migration, ajoutez les trois colonnes entre id() et timestamps() :
Schema::create('contact_messages', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->text('message');
$table->timestamps();
});
text accepte un message plus long que les 255 caractères d'une colonne string. Dans app/Models/ContactMessage.php, déclarez les colonnes remplissables avec l'attribut du chapitre 1.06 :
#[Fillable(['name', 'email', 'message'])]
class ContactMessage extends Model
{
//
}
Lancez la migration :
php artisan migrate
2026_10_27_090831_create_contact_messages_table .. 6.68ms DONE
2. La Form Request
Les règles de validation vivent dans une Form Request, comme pour la création d'un projet au chapitre 1.12 :
php artisan make:request ContactMessageRequest
Dans app/Http/Requests/ContactMessageRequest.php, supprimez la méthode authorize(), comme au chapitre 1.12, et écrivez :
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'max:5000'],
];
}
public function attributes(): array
{
return ['name' => 'nom'];
}
La règle email refuse une adresse mal formée, comme pas-une-adresse. La méthode attributes() donne le nom français du champ dans les messages d'erreur. Les traductions installées au chapitre 1.12 connaissent déjà « adresse e-mail » et « message ». Elles ne connaissent pas name : sans cette ligne, l'erreur parlerait du champ « name ».
3. Le Mailable
Un Mailable est une classe qui décrit un e-mail : son objet, à qui répondre, et le template de son contenu. Laravel en crée un avec la commande make:mail. L'option --markdown lui donne un template écrit en Markdown, que Laravel transforme en un e-mail mis en page :
php artisan make:mail ContactMessageReceived --markdown=mail.contact-message
INFO Mailable [C:\…\portfolio\app\Mail\ContactMessageReceived.php] created successfully.
INFO Markdown view [C:\…\portfolio\resources\views\mail\contact-message.blade.php] created successfully.
La classe générée commence par use Queueable, SerializesModels; et importe ShouldQueue sans s'en servir. Ces lignes servent à mettre l'e-mail dans une file d'attente, une liste de tâches qu'un autre processus exécute plus tard. Le portfolio n'en utilise pas. Un e-mail ne part dans une file que si sa classe implémente ShouldQueue : retirez ces lignes pour lever le doute. Retirez aussi la méthode attachments(), puisque le message n'a pas de pièce jointe. Remplacez la classe par :
class ContactMessageReceived extends Mailable
{
public function __construct(public ContactMessage $contactMessage) {}
public function envelope(): Envelope
{
return new Envelope(
replyTo: [new Address($this->contactMessage->email, $this->contactMessage->name)],
subject: 'Nouveau message de '.$this->contactMessage->name,
);
}
public function content(): Content
{
return new Content(markdown: 'mail.contact-message');
}
}
En tête du fichier, gardez les imports de Mailable, Content et Envelope, et ajoutez use App\Models\ContactMessage; et use Illuminate\Mail\Mailables\Address;.
Le constructeur reçoit le message enregistré. Sa propriété publique $contactMessage est disponible dans le template. envelope() fixe l'objet de l'e-mail, et replyTo l'adresse de réponse : quand vous cliquez sur « Répondre » dans votre messagerie, vous écrivez au visiteur, pas à vous-même. content() désigne le template resources/views/mail/contact-message.blade.php. Remplacez son contenu :
<x-mail::message>
# Nouveau message depuis le portfolio
**De :** {{ $contactMessage->name }} ({{ $contactMessage->email }})
{{ $contactMessage->message }}
Répondez directement à cet e-mail pour lui écrire.
</x-mail::message>
<x-mail::message> est le composant de Laravel qui donne à l'e-mail son en-tête, sa mise en page et son pied. À l'intérieur, # écrit un titre et ** du gras, comme en Markdown. {{ }} échappe le texte du visiteur, comme dans toute vue Blade.
4. Le contrôleur et les routes
Créez le contrôleur :
php artisan make:controller ContactController
Il a deux méthodes, une pour afficher le formulaire et une pour le recevoir :
public function create(): Response
{
return Inertia::render('Contact');
}
public function store(ContactMessageRequest $request): RedirectResponse
{
$contactMessage = ContactMessage::create($request->validated());
Mail::to(config('mail.from.address'))->send(new ContactMessageReceived($contactMessage));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Merci, votre message est bien parti.']);
return to_route('contact');
}
Les imports vont en tête du fichier : ContactMessageRequest, ContactMessageReceived, ContactMessage, RedirectResponse, la façade Mail (Illuminate\Support\Facades\Mail), Inertia et Inertia\Response.
Suivez store() dans l'ordre.
- Laravel valide la requête avant d'appeler la méthode. Si une règle échoue,
store()ne s'exécute pas, et les erreurs reviennent au formulaire. ContactMessage::create()enregistre le nom, l'adresse et le message, et seulement eux :validated()ne contient que les champs des règles.Mail::to(...)->send(...)construit l'e-mail et l'envoie tout de suite. PHP attend la réponse du serveur d'e-mail avant de passer à la ligne suivante. L'envoi dure donc le temps de la requête, sans file d'attente.Inertia::flash('toast', ...)prépare le message de confirmation du chapitre 1.12, etto_route('contact')renvoie le visiteur au formulaire.
Le destinataire est config('mail.from.address'), c'est-à-dire la variable MAIL_FROM_ADDRESS de .env. C'est l'adresse qui envoie les e-mails du site, et c'est aussi la vôtre : le message vous arrive. Le kit y met hello@example.com. En local, la valeur ne compte pas encore. En ligne, ce sera votre vraie adresse (chapitre 1.22).
Dans routes/web.php, remplacez la ligne Route::inertia('contact', 'Contact') du chapitre 1.08 par deux routes, avec use App\Http\Controllers\ContactController; en tête du fichier :
Route::get('contact', [ContactController::class, 'create'])->name('contact');
Route::post('contact', [ContactController::class, 'store'])->name('contact.store');
La même adresse sert deux fois : GET /contact affiche le formulaire, POST /contact le reçoit. Le nom contact ne change pas, et le lien du menu fonctionne toujours.
5. La page
Remplacez resources/js/pages/Contact.vue :
<script setup>
import { Form, Head } from '@inertiajs/vue3';
import ContactController from '@/actions/App/Http/Controllers/ContactController';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
</script>
<template>
<Head title="Contact">
<meta
head-key="description"
name="description"
content="Écrire à Nora Benali : une question, un projet, un stage."
/>
</Head>
<div class="max-w-xl space-y-6 py-8">
<h1 class="font-display text-3xl font-bold md:text-4xl">Contact</h1>
<Form
v-bind="ContactController.store.form()"
:reset-on-success="['name', 'email', 'message']"
v-slot="{ errors, processing }"
class="space-y-6"
>
<div class="grid gap-2">
<Label for="name">Nom</Label>
<Input id="name" name="name" autocomplete="name" />
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="email">Adresse e-mail</Label>
<Input
id="email"
name="email"
type="email"
autocomplete="email"
/>
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<Label for="message">Message</Label>
<Textarea id="message" name="message" rows="6" />
<InputError :message="errors.message" />
</div>
<Button type="submit" :disabled="processing">
<Spinner v-if="processing" />
Envoyer
</Button>
</Form>
</div>
</template>
<Head> garde la description du chapitre 1.19, avec votre phrase. Le reste de la page reprend le formulaire du chapitre 1.12. ContactController.store.form() vient des fichiers de Wayfinder rangés par contrôleur (chapitre 1.08). Il donne à <Form> l'adresse /contact et la méthode post. Chaque champ a un name, que la Form Request lit, et une erreur affichée sous lui avec InputError. Textarea est le composant ajouté au chapitre 1.11.
Après un envoi réussi, Laravel redirige vers la même page. Sans :reset-on-success, le message resterait dans les champs, et le visiteur ne saurait pas s'il est parti. Les trois champs nommés se vident, et le message flash s'affiche grâce au <Toaster /> ajouté au layout public au chapitre 1.18.
6. En local, l'e-mail est dans le journal
En développement, aucun e-mail ne doit partir pour de vrai. Le .env du kit contient MAIL_MAILER=log : Laravel écrit chaque e-mail dans son journal, le fichier storage/logs/laravel.log, au lieu de l'envoyer.
Envoyez un message depuis portfolio.test/contact. Le formulaire se vide, et « Merci, votre message est bien parti. » s'affiche en bas de l'écran. Ouvrez ensuite storage/logs/laravel.log dans votre éditeur et cherchez Subject:. Voici l'e-mail envoyé par « Awa Diallo » dans le labo, coupé :

[2026-10-27 09:10:35] local.DEBUG: From: Nora Benali <hello@example.com>
To: hello@example.com
Reply-To: Awa Diallo <awa@example.com>
Subject: Nouveau message de Awa Diallo
…
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Nora Benali: http://127.0.0.1:8000
# Nouveau message depuis le portfolio
**De :** Awa Diallo (awa@example.com)
Bonjour, votre quiz des capitales me plaît beaucoup.
Répondez directement à cet e-mail pour lui écrire.
…
Content-Type: text/html; charset=utf-8
L'e-mail part de MAIL_FROM_ADDRESS, avec APP_NAME comme nom d'expéditeur, et il arrive à la même adresse. Reply-To désigne le visiteur. Le journal contient l'e-mail entier, en texte puis en HTML, avec plusieurs centaines de lignes de styles : cherchez Subject: plutôt que de faire défiler le fichier. L'heure du journal est en temps universel, une heure de moins qu'à Wavre en hiver et deux en été, parce que config/app.php du kit règle le fuseau horaire à UTC.
Le message est aussi en base. Vérifiez-le avec Tinker :
php artisan tinker --execute="echo App\Models\ContactMessage::pluck('name');"
["Awa Diallo"]
En ligne, MAIL_MAILER=smtp et les réglages du serveur d'e-mail d'OVH feront partir le même e-mail pour de vrai. Le chapitre 1.22 les donne.
Envoyez enfin le formulaire vide. Trois erreurs s'affichent sous les champs : « Le champ nom est obligatoire. », « Le champ adresse e-mail est obligatoire. » et « Le champ message est obligatoire. ».

7. Bonus : freiner le spam
Un formulaire de contact public attire des robots qui l'envoient des centaines de fois. Une protection simple suffit pour un portfolio : une limite d'envois. Elle est en bonus.
Le middleware throttle de Laravel limite le nombre de requêtes. Ajoutez-le à la route POST :
Route::post('contact', [ContactController::class, 'store'])
->middleware('throttle:3,1')
->name('contact.store');
throttle:3,1 accepte trois envois par minute et par adresse IP. Les envois refusés par la validation comptent aussi. Au quatrième envoi dans la même minute, Laravel répond par le statut 429 Too Many Requests.
Sans rien de plus, Inertia affiche alors la page d'erreur de Laravel, « 429 | Trop de requêtes », dans une fenêtre posée par-dessus le site. La réponse n'est pas une page Inertia, et le gestionnaire Inertia::handleExceptionsUsing() du chapitre 1.08 ne traite que les codes 403 et 404. Ajoutez 429 à sa liste, dans app/Providers/AppServiceProvider.php :
if (in_array($response->statusCode(), [403, 404, 429])) {
Le code arrive maintenant dans la prop status de ErrorPage.vue, qui ne connaît que deux titres. Remplacez son script par une table des titres et une table des messages, un par code :
<script setup>
import { Head, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import { home } from '@/routes';
const props = defineProps({
status: Number,
});
const titles = {
403: 'Accès refusé',
404: 'Page introuvable',
429: "Trop d'envois",
};
const messages = {
403: "Vous n'avez pas accès à cette page.",
404: "Cette page n'existe pas ou n'est plus en ligne.",
429: 'Réessayez dans une minute.',
};
const title = computed(() => titles[props.status]);
const message = computed(() => messages[props.status]);
</script>
Dans le template, le paragraphe devient <p>{{ message }}</p>. titles[props.status] lit dans l'objet la valeur rangée sous le code reçu, comme $tableau[$cle] en PHP. Le quatrième envoi affiche « 429 · Trop d'envois » et « Réessayez dans une minute. » dans votre layout public, avec le lien vers l'accueil.

8. Les tests
Mail::fake() remplace l'envoi des e-mails par un enregistrement en mémoire, que le test interroge ensuite. Rien ne part, et rien n'est écrit dans le journal. Créez tests/Feature/ContactTest.php :
use App\Mail\ContactMessageReceived;
use App\Models\ContactMessage;
use Illuminate\Support\Facades\Mail;
test('a visitor can send a message', function () {
Mail::fake();
$this->post('/contact', [
'name' => 'Awa Diallo',
'email' => 'awa@example.com',
'message' => 'Bonjour, votre quiz me plaît beaucoup.',
])
->assertRedirect('/contact')
->assertInertiaFlash('toast.message', 'Merci, votre message est bien parti.');
expect(ContactMessage::count())->toBe(1);
Mail::assertSent(ContactMessageReceived::class, fn ($mail) => $mail->hasReplyTo('awa@example.com'));
});
test('the contact form is validated', function () {
Mail::fake();
$this->post('/contact', ['name' => '', 'email' => 'pas-une-adresse', 'message' => ''])
->assertSessionHasErrors(['name', 'email', 'message']);
Mail::assertNothingSent();
});
assertInertiaFlash() vérifie le message flash, et Mail::assertSent() vérifie qu'un e-mail de cette classe est parti, avec la bonne adresse de réponse. Si vous faites le bonus, un test de plus le couvre :
test('the contact form is throttled', function () {
Mail::fake();
$data = ['name' => 'Awa', 'email' => 'awa@example.com', 'message' => 'Bonjour.'];
$this->post('/contact', $data);
$this->post('/contact', $data);
$this->post('/contact', $data);
$this->post('/contact', $data)
->assertTooManyRequests()
->assertInertia(fn (Assert $page) => $page->component('ErrorPage')->where('status', 429));
});
assertTooManyRequests() vérifie le code 429, et assertInertia() vérifie que la réponse est bien votre page ErrorPage avec ce code en prop, comme au chapitre 1.17. Importez Inertia\Testing\AssertableInertia as Assert en tête du fichier.
php artisan test tests/Feature/ContactTest.php
PASS Tests\Feature\ContactTest
✓ a visitor can send a message
✓ the contact form is validated
✓ the contact form is throttled
Tests: 3 passed (21 assertions)
9. Pratique : votre formulaire de contact (20 min)
Temps annoncé : 20 minutes.
Refaites les sections 1 à 6 sur votre portfolio, puis vérifiez chaque critère.
- Envoyé vide, le formulaire affiche une erreur en français sous chaque champ.
- Une adresse comme
pas-une-adresseest refusée. - Après un envoi valide, les champs se vident et le message de confirmation s'affiche.
- Tinker trouve le message dans la table
contact_messages. -
storage/logs/laravel.logcontient l'e-mail, avecSubject: Nouveau message desuivi du nom et leReply-Todu visiteur. - Les deux premiers tests de
ContactTest.phpsont verts, etphp artisan testaussi. - Bonus : le quatrième envoi dans la minute affiche la page « 429 · Trop d'envois » du site.
Ce que votre portfolio contient maintenant
Une page de contact qui valide le message, l'enregistre, l'envoie par e-mail à l'auteur du site et confirme l'envoi au visiteur. En local, l'e-mail s'écrit dans le journal de Laravel. Les tests vérifient l'envoi et la validation.
À retenir
- Un
Mailabledécrit un e-mail :envelope()pour l'objet et l'adresse de réponse,content()pour le template. Mail::to(...)->send(...)envoie l'e-mail pendant la requête, tant que la classe n'implémente pasShouldQueue.- Avec
MAIL_MAILER=log, l'e-mail s'écrit dansstorage/logs/laravel.log, où vous le trouvez en cherchantSubject:. :reset-on-successvide les champs, et le message flash a besoin du<Toaster />dans le layout public.throttle:3,1limite les envois à trois par minute et par adresse IP ; au-delà, le site répond 429 dans sa propre page d'erreur.