Skip to content

4. The View and Its Model

Up to this point, actions have generated their own responses: text, JSON, and a few HTML tags written as a string of characters. In a MVC application, this is the role of a view: a file that describes the page HTML, with placeholders for the data passed to it by the action—the view’s model.

[Laravel] comes with its own view engine, [Blade]. A [Blade] view is a file whose name ends with [.blade.php], stored in the application’s [resources/views] folder. It is a text file—in this case, HTML—in which three types of tags are interpreted on the server:

  • [{{ expression }}]: the value of a [PHP] expression, inserted into the page;
  • [@instruction]: a directive: condition ([@if]), loop ([@foreach]), template inheritance ([@extends], [@section]), inclusion ([@include])... A directive begins with [@], followed by its name;
  • [{{-- commentaire --}}]: a comment, which does not appear on the generated page.

Everything else is copied as-is. Expressions are written in [PHP]: there is no second language to learn. [Blade] thus resembles [Razor], the view engine of [ASP.NET Core MVC], which also mixes HTML and code, and which also uses the character [@]; it differs from [Thymeleaf] ([Spring MVC]), whose views are valid HTML files enriched with [th:] attributes. A [Blade] view is not executed as-is: upon its first use, [Blade] compiles it into a standard [PHP] file, stored in [storage/framework/views], which it then reuses as long as the view remains unchanged. We’ll see what this file looks like.

4.1. Example 13: A First View

13-first-view/
├── app/
│   ├── Http/
│   │   └── Controllers/
│   │       └── ViewsController.php
│   └── Models/
│       └── PageModel.php
├── resources/
│   └── views/
│       └── views/
│           ├── unknown.blade.php
│           └── index.blade.php
├── routes/
│   └── web.php
└── ...

4.1.1. The view template: app/Models/PageModel.php

<?php
// app/Models/PageModel.php: the model of the view, the data that the action passes to the view

namespace App\Models;

readonly class PageModel
{
    public function __construct(
        public string $title,
        public string $message,
        public string $time,
    ) {
    }
}

The view model is a simple immutable class (see Example 03): the three pieces of information that the view will display. There’s no requirement to define a view’s model as a class: you could pass three separate variables to the view. However, a class documents what the view expects and allows the editor to verify the action’s code. This is the approach the case study will follow, where each view has its own model class.

4.1.2. The controller: app/Http/Controllers/ViewsController.php

<?php
// app/Http/Controllers/ViewsController.php: actions that return a VIEW

namespace App\Http\Controllers;

use App\Models\PageModel;
use Illuminate\Http\Response;
use Illuminate\View\View;

class ViewsController
{
    // view: the Blade engine builds the page from the view
    // resources/views/views/index.blade.php and from the variables passed in the
    // array (here a single one, [model]); the produced page is the response of the action
    public function index(): View
    {
        $model = new PageModel(
            'My first view',
            'This page was built by Blade, on the server.',
            date('H:i:s'));
        return view('views.index', ['model' => $model]);
    }

    // the same view with another model; or another view, with the 404 code
    public function hello(string $name): View|Response
    {
        if ($name === 'unknown') {
            // the page, sent with the 404 code
            return response()->view('views.unknown', ['name' => $name], 404);
        }
        $model = new PageModel(
            "Hello $name",
            'This time, the title of the page comes from the URL.',
            date('H:i:s'));
        return view('views.index', ['model' => $model]);
    }
}
  • Lines 17–20: The action builds the view model. The function ***[date] from *[PHP] formats the current time: [H] for the hours, [i] for the minutes, and [s]** for the seconds;
  • Line 21: The function [view] causes [Blade] to generate the page. The first parameter is the view name: the file path, relative to the [resources/views] folder, where the [/]s are replaced by dots, and without the [.blade.php] extension: [views.index] refers to the file [resources/views/views/index.blade.php]. The second parameter is an associative array, where each key becomes a variable in the view—in this case, a single one: [$model]. [view] returns an object [View], which [Laravel] transforms into a response (of type [text/html]) by generating the page;
  • Line 29: another view, with a different variable ([$name]). [response()->view(...)] generates a response from a view, using the specified code—in this case, 404—to display a custom error page. The action type [View|Response] means: either a [View] or a [Response];
  • lines 31–35: the same view as the [index] action, but with a different template. This is the benefit of separating actions from views: a single view can be used by multiple actions.

4.1.3. The view: resources/views/views/index.blade.php

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  {{-- {{ ... }}: the value of an expression, inserted into the page --}}
  <title>{{ $model->title }}</title>
</head>
<body>
  <h1>{{ $model->title }}</h1>
  <p>{{ $model->message }}</p>
  <p>Page built at {{ $model->time }}.</p>
</body>
</html>
  • line 5: a comment [Blade];
  • line 6: [{{ $model->title }}] inserts the value of the [title] property of the [$model] variable. Between the curly braces, you can write any [PHP] expression.

Everything inserted by [{{ }}] is escaped: the characters [<], [>], [&], ["], and ['] are replaced by their entities HTML. This is the role that the [e] function played in Example 09, but this time it is automatic: a view therefore cannot inadvertently insert the HTML provided by a user into the page.

4.1.4. The view: resources/views/views/unknown.blade.php

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [{{ $name }}] here.</h1>
</body>
</html>

A view is not required to receive an object: the variable [$name] is a string.

4.1.5. Execution

Image

Image

The displayed time is in Coordinated Universal Time (UTC), with the default time zone set to [Laravel]; the case study will show how to change this. The raw output shows the processing done by [Blade]: the [{{ }}] tags have been replaced with their values, and the comment has been removed (a blank line remains in its place).

curl -i http://localhost:8000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">

  <title>My first view</title>
</head>
<body>
  <h1>My first view</h1>
  <p>This page was built by Blade, on the server.</p>
  <p>Page built at 16:04:07.</p>
</body>
</html>

The “home” error page, followed by a name containing HTML ([<b>martin], encoded in URL):

curl -i http://localhost:8000/hello/unknown
curl http://localhost:8000/hello/%3Cb%3Emartin
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Unknown</title>
</head>
<body>
  <h1>Nobody is called [unknown] here.</h1>
</body>
</html>
...
  <title>Hello &lt;b&gt;martin</title>
...
  <h1>Hello &lt;b&gt;martin</h1>
...

The [<b>] tag was escaped, even though neither the action nor the view had anything to do with it. Here, finally, is the view compiled by [Blade], as found in the [storage/framework/views] folder (under a calculated name, such as [bfadc851307fb914e48325630a6c9191.php]):

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">

  <title><?php echo e($model->title); ?></title>
</head>
<body>
  <h1><?php echo e($model->title); ?></h1>
  <p><?php echo e($model->message); ?></p>
  <p>Page built at <?php echo e($model->time); ?>.</p>
</body>
</html>
<?php /**PATH .../13-first-view/resources/views/views/index.blade.php ENDPATH**/ ?>

Each [{{ expression }}] has become [<?php echo e(expression); ?>]: the conversion is performed by the [e] function from Example 09. A [Blade] view is therefore just a more elegant way of writing a [PHP] file that generates HTML. [PHP] was originally designed as a page language, where the code is inserted into the HTML file between [<?php] and [?>]; [Blade] retains its efficiency and corrects its flaws (notably the missing escape sequence).

4.2. Example 14: The Blade Language

This example brings together the main constructs of [Blade] in a single view.

4.2.1. The Model and the Action

The view’s model is described by three immutable classes, each in its own file ([PHP] does not require one class per file, but the class loader in [Composer] does look for the class [App\Models\Product] in the file [app/Models/Product.php]):

<?php
// app/Models/Product.php: a product

namespace App\Models;

readonly class Product
{
    public function __construct(
        public string $name,
        public float $price,
        public bool $inStock,
    ) {
    }
}
<?php
// app/Models/Address.php: an address

namespace App\Models;

readonly class Address
{
    public function __construct(
        public string $street,
        public string $city,
    ) {
    }
}
<?php
// app/Models/BasicsModel.php: the model of the view of example 14

namespace App\Models;

readonly class BasicsModel
{
    /**
     * @param list<Product> $products
     * @param list<Product> $cart
     */
    public function __construct(
        public string $title,
        public string $name,
        public ?string $nickname,
        public string $html,
        public bool $loggedIn,
        public string $currency,
        public array $products,
        public array $cart,
        public Address $address,
    ) {
    }
}

The model contains values of various types: strings, [null], Booleans, arrays of objects, empty arrays, and objects containing other objects. The prices here are of type [float], to simplify calculations in the view (Example 06 showed why an exact decimal is preferred).

<?php
// app/Http/Controllers/BasicsController.php: a view that uses the main constructs of Blade

namespace App\Http\Controllers;

use App\Models\Address;
use App\Models\BasicsModel;
use App\Models\Product;
use Illuminate\View\View;

class BasicsController
{
    public function index(): View
    {
        $model = new BasicsModel(
            title: 'Blade basics',
            name: 'Martin',
            nickname: null,
            html: '<b>this is HTML</b>',
            loggedIn: true,
            currency: '€',
            products: [
                new Product('Keyboard', 25, true),
                new Product('Mouse', 12.5, false),
                new Product('Screen', 149, true),
            ],
            cart: [],
            address: new Address('12 rue des Lilas', 'Angers'));
        return view('basics.index', ['model' => $model]);
    }
}

The action constructs the model using named parameters (see Example 01): each value is preceded by the name of the parameter it is intended for, which makes the call readable despite the number of parameters.

4.2.2. The view: resources/views/basics/index.blade.php

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{ $model->title }}</title>
</head>
<body>
  {{-- PHP code in the view, between the two instructions that follow; here, two variables --}}
  @php
    $total = collect($model->products)->sum('price');
    $count = count($model->products);
  @endphp
  <h1>{{ $model->title }}</h1>
  {{-- 1. display a value of the model; null is displayed as an empty string;
       the ?? operator replaces a null value. This Blade comment does not appear
       in the produced page --}}
  <p>1. Name: {{ $model->name }} - nickname: [{{ $model->nickname }}] [{{ $model->nickname ?? 'none' }}]</p>
  {{-- 2. {{ }} escapes HTML; {!! !!} inserts it as is --}}
  <p>2a. {{ $model->html }}</p>
  <p>2b. {!! $model->html !!}</p>
  {{-- 3. a condition --}}
  @if ($model->loggedIn)
    <p>3. You are logged in.</p>
  @else
    <p>3. You are not logged in.</p>
  @endif
  {{-- 4. a loop: $loop describes the state of the loop --}}
  <p>4. Our products:</p>
  <ul>
    @foreach ($model->products as $product)
      <li>no. {{ $loop->index }}: {{ $product->name }}, {{ $product->price }} {{ $model->currency }}
        {{ $product->inStock ? '(in stock)' : '(sold out)' }}
        @if ($loop->first) - the first one @endif
        @if ($loop->last) - the last one, seen by {{ $model->name }} @endif
      </li>
    @endforeach
  </ul>
  {{-- 5. the empty list: @forelse ... @empty --}}
  <p>5. Your cart:
    @forelse ($model->cart as $p)<span>{{ $p->name }}</span>@empty it is empty. @endforelse
  </p>
  {{-- 6. computed expressions, the @php variables, and number_format --}}
  <p>6. {{ $count }} products, for a total of {{ $total }} {{ $model->currency }};
    average price: {{ number_format($total / $count, 2, '.', ',') }} {{ $model->currency }}</p>
  {{-- 7. an object inside an object, and a PHP function (mb_strtoupper) --}}
  <p>7. Address: {{ $model->address->street }}, {{ mb_strtoupper($model->address->city) }}</p>
  {{-- 8. @ is a Blade instruction only at the start of a word; @@ writes an @ --}}
  <p>8. An email address: contact@example.com; a displayed instruction: @@if</p>
</body>
</html>
  • Lines 9–12: The directives [@php] and [@endphp] delimit a block of code [PHP], which is executed at this point in the page. Two view variables are created there: the total price, calculated by the [sum] method of a collection (example 03), which sums a property of each element, and the number of products. The [@php] block should be reserved for this type of preparation: the calculation normally belongs to the action;
  • line 17: a [null] value is displayed as an empty string. The [??] operator in [PHP] (Example 08) replaces a [null] value with a default value;
  • Lines 19–20: [{{ }}] escapes HTML (2a); [{!! !!}], on the other hand, inserts the value as-is, without escaping it (2b). [{!! !!}] is reserved for HTML generated by the application itself, never for user input;
  • lines 22–26: a condition: [@if], [@elseif], [@else], [@endif], with a [PHP] condition in parentheses. Each block is closed by a [@end...] directive;
  • lines 30–36: a loop [@foreach] ... [@endforeach], which is written as the loop [foreach] of [PHP]. Within it, the special variable [$loop] describes the state of the loop: [$loop->index] (the iteration number, starting at 0), [$loop->iteration] (starting at 1), [$loop->first], [$loop->last] (true on the first and last iterations), [$loop->count] (the number of elements). Variables outside the loop ([$model->name]) remain accessible;
  • Line 32: the ternary operator for [PHP], [condition ? a : b] (Example 11);
  • line 40: the directive [@forelse] ... [@empty] ... [@endforelse] is a loop in which the block [@empty] is executed if the list is empty. This is simpler than a [@if] that would test the length of the list;
  • lines 43–44: calculated expressions ([$total / $count]) and the variables from the [@php] block. The function [number_format] from [PHP] formats a number: two decimal places, a period as the decimal separator, and a comma as the thousands separator. In Example 16, we’ll see a much better way to format numbers and amounts;
  • Line 46: The [->] operator iterates through the objects ([$model->address->city]), and any [PHP] function can be called: [mb_strtoupper] capitalizes a string (the [mb_...] functions, for *multi-byte*, correctly handle accented characters encoded in UTF-8);
  • line 48: the character [@] is interpreted by [Blade] only at the beginning of a word; in an email address, it is copied as-is. To write a [@] followed by a directive name, it is doubled: [@@if] produces [@if]. Similarly, [@{{ ... }}] produces [{{ ... }}], which is useful when a page contains curly braces intended for a framework [JavaScript].

A note on comments: [Blade] processes [@php] blocks before comments. A comment containing the word [@php] would therefore be interpreted as the start of a code block, with unexpected results: this is why the comment on line 8 does not include it.

4.2.3. Execution

Image

The generated page:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Blade basics</title>
</head>
<body>

    <h1>Blade basics</h1>

  <p>1. Name: Martin - nickname: [] [none]</p>

  <p>2a. &lt;b&gt;this is HTML&lt;/b&gt;</p>
  <p>2b. <b>this is HTML</b></p>

      <p>3. You are logged in.</p>

  <p>4. Our products:</p>
  <ul>
          <li>no. 0: Keyboard, 25 €
        (in stock)
         - the first one               </li>
          <li>no. 1: Mouse, 12.5 €
        (sold out)
                      </li>
          <li>no. 2: Screen, 149 €
        (in stock)
                 - the last one, seen by Martin       </li>
      </ul>

  <p>5. Your cart:
     it is empty.   </p>

  <p>6. 3 products, for a total of 186.5 €;
    average price: 62.17 €</p>

  <p>7. Address: 12 rue des Lilas, ANGERS</p>

  <p>8. An email address: contact@example.com; a displayed instruction: @if</p>
</body>
</html>

The comments and directives have disappeared, but they leave behind their spaces and line breaks: [Blade] does not attempt to produce a neatly formatted HTML. This has no consequence: the browser ignores these whitespace characters. A number like [float] inserted into a string is written with a decimal point ([12.5]); [number_format] uses the separators passed to it.

4.3. Example 15: Templates, Fragments, Static Files

The pages of a website usually share the same structure: a header, a navigation bar, a footer, and the same style sheets. Rather than repeating this structure in every view, it is written once in a template (*layout*), which the pages extend. This example also uses [Bootstrap], the most widely used CSS framework, for the layout.

15-layout/
├── app/
│   ├── Http/
│   │   └── Controllers/
│   │       └── PagesController.php
│   └── Models/
│       └── Member.php
├── bootstrap/
│   └── app.php
├── public/
│   └── css/
│       └── styles.css
├── resources/
│   └── views/
│       ├── components/
│       │   └── card.blade.php
│       ├── fragments/
│       │   └── navbar.blade.php
│       ├── layout.blade.php
│       └── pages/
│           ├── home.blade.php
│           ├── raw.blade.php
│           └── team.blade.php
├── routes/
│   └── web.php
└── ...

4.3.1. Static Files

A HTML page references other files: style sheets, [JavaScript] scripts, and images. These files, which the server sends as-is, are called static files. With [Laravel], they are stored in the [public] folder, which the web server serves as-is (example 01): The file [public/css/styles.css] is available at URL [/css/styles.css], without [Laravel] being involved.

/* a few settings in addition to Bootstrap */
body {
  background-color: #f5f7fa;
}

/* the footer of the layout */
.footer {
  margin-top: 2rem;
  font-size: 0.85rem;
  color: #6c757d;
}

/* the error messages, at the bottom of the page, on a light pink background */
.error-area {
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef;
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

A few additional CSS rules in addition to [Bootstrap]: a light gray background, the footer style, and the style for an error area that will be used in the following examples.

[Bootstrap] is used in examples 15 through 28. Rather than placing a copy in each user’s [public] folder, the examples share it: it is located in the [examples/common/bootstrap] folder. This folder is not served by the web server: a route, common to all these examples, serves its files. It is described by the file [examples/common/bootstrap.php]:

<?php
// common/bootstrap.php: a route that serves the Bootstrap files, shared by
// examples 15 to 28 (the examples/common/bootstrap folder), under the /bootstrap/... URL

use Illuminate\Support\Facades\Route;

Route::get('bootstrap/{file}', function (string $file) {
    $folder = realpath(__DIR__.'/bootstrap');
    // realpath: the absolute path of the requested file, without the . and the .. ; false
    // if the file does not exist
    $path = realpath($folder.'/'.$file);
    // a file that exists, and that is indeed IN the folder (no ../ to get out of it)
    abort_unless($path && str_starts_with($path, $folder.DIRECTORY_SEPARATOR)
        && is_file($path), 404);
    // the type of the file, from its extension
    $types = ['css' => 'text/css', 'js' => 'text/javascript', 'map' => 'application/json'];
    $type = $types[pathinfo($path, PATHINFO_EXTENSION)] ?? 'application/octet-stream';
    return response()->file($path, ['Content-Type' => $type]);
})->where('file', '.*')->name('bootstrap');
  • line 7: a route whose action is an anonymous function (example 01). The route parameter [{file}] accepts any path ending, including slashes (constraint [.*], line 19, see example 02): the URL [/bootstrap/css/bootstrap.min.css] refers to the file [css/bootstrap.min.css] in the folder. The path is named [bootstrap];
  • lines 8–14: Serving files designated by URL requires a precaution: a URL such as [/bootstrap/../../15-layout/.env] must not grant access to a file located outside the folder. The [realpath] function of [PHP] calculates the absolute path of a file, stripped of its [.] and [..] components, or returns [false] if the file does not exist. We verify that the resulting path does indeed start with the folder path ([str_starts_with]) and that it refers to a file ([is_file]); otherwise, [abort_unless] terminates the request with a 404 error. [abort_unless(condition, code)] is equivalent to [if (! condition) abort(code)]; similarly, [abort_if] also exists. [DIRECTORY_SEPARATOR] is the system path separator: [\] under [Windows], [/] elsewhere;
  • lines 16–18: [response()->file] generates a response that sends the contents of a file. The content type is chosen based on the file extension (provided by [pathinfo]), so that a stylesheet is correctly declared as [text/css]: a browser will reject a stylesheet declared as something else.

The route is declared by the [bootstrap/app.php] file in each example:

<?php
// bootstrap/app.php: the application of example 15

use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\View;

(require __DIR__.'/../../vendor/autoload.php')->addPsr4('App\\', __DIR__.'/../app/');

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        // then: routes declared outside the web group (without session or cookies):
        // the one that serves Bootstrap, shared by examples 15 to 28
        then: fn () => require __DIR__.'/../../common/bootstrap.php')
    ->withMiddleware()
    ->withExceptions()
    // booted: code executed once the application has started
    ->booted(function () {
        // a "view composer": executed before the building of EACH view (*),
        // it adds its variables to all the views
        View::composer('*', function ($view) {
            $view->with(['path' => request()->path(), 'year' => date('Y')]);
        });
    })
    ->create();
  • lines 11–14: the [then] parameter of [withRouting] is a function, called after the routes in [routes/web.php] have been declared, to declare additional routes. These are not part of the [web] group: they have neither a session nor cookies, which is suitable for static files. The arrowed function executes the file [common/bootstrap.php];
  • lines 18–24: the [booted] method registers a function that is executed once the application starts;
  • lines 21–23: the [View] facade registers a view composer: a function that [Laravel] executes before rendering each view whose name matches the given pattern, in this case [*] (all of them). It receives the view and can add variables to it ([with]). Here, [$path] (the path to the requested URL, provided by [request()], the current request) and [$year] (the current year) are thus available in all views, without the actions having to provide them. The template uses them for its footer. The method [View::share('name', value)] would similarly share a fixed value with all views.

4.3.2. The template: resources/views/layout.blade.php

<!doctype html>
{{-- the layout of the pages: the pages EXTEND it, and fill its sections --}}
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {{-- @yield: a slot that the page can fill; here with a default content --}}
  <title>@yield('title', 'Home') - My site</title>
  {{-- route(): the URL of the [bootstrap] named route; asset(): the URL of a file of public/ --}}
  <link rel="stylesheet" href="{{ route('bootstrap', 'css/bootstrap.min.css') }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3">
    {{-- insertion of the fragments/navbar.blade.php file --}}
    @include('fragments.navbar')
    <main>
      {{-- the content of the page requested by the action is inserted here --}}
      @yield('content')
    </main>
    {{-- $path, $year: added to all the views (see bootstrap/app.php) --}}
    <footer class="footer">Page [{{ $path }}] - © {{ $year }}</footer>
  </div>
</body>
</html>
  • line 6: the [viewport] tag instructs mobile browsers to display the page at the screen’s width, which is what [Bootstrap] expects;
  • Line 8: The directive [@yield] marks a named slot—which pages that extend the template can fill—with a section of the same name. The second parameter is the default content for the slot, used by a page that does not provide the section;
  • Lines 10–11: [route('bootstrap', 'css/bootstrap.min.css')] generates the URL for the named route [bootstrap] (Example 04), using the value of its parameter [{file}]. The function [asset] creates URL from a file in the [public] folder. Writing the URL file manually ([/css/styles.css]) would also work, but [asset] works if the application is installed under a URL prefix;
  • line 16: [@include] inserts the content of another view, in this case the navigation bar. The included view has access to the variables of the view that includes it;
  • line 19: the area that each page fills with its own content;
  • line 22: the footer uses the two variables from the view composer.

The class [container] of [Bootstrap] centers the content and limits its width; [py-3] adds vertical padding: [Bootstrap] provides hundreds of classes like this, which are combined in the [class] attribute.

4.3.3. The navigation bar: resources/views/fragments/navbar.blade.php

1
2
3
4
5
6
7
8
9
{{-- a fragment: a piece of page, inserted by @include --}}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">My site</span>
  <div class="navbar-nav">
    <a class="nav-link" href="/">Home</a>
    <a class="nav-link" href="/team">Team</a>
    <a class="nav-link" href="/raw">Page without layout</a>
  </div>
</nav>

A fragment: a piece of a page that has no meaning on its own. The classes [navbar], [navbar-brand], and [nav-link] are those of the navigation bar in [Bootstrap].

4.3.4. A component: resources/views/components/card.blade.php

1
2
3
4
5
6
7
8
{{-- a COMPONENT: a piece of page that has parameters, like a function --}}
@props(['name', 'role'])
<div class="card">
  <div class="card-body">
    <h5 class="card-title">{{ $name }}</h5>
    <p class="card-text">{{ $role }}</p>
  </div>
</div>
  • Line 2: A component is a section of a page that has parameters; this is the function of [Blade]. An anonymous component is a simple view from the [resources/views/components] folder; the [@props] directive declares its parameters, which become variables of the view. It is used as a HTML tag whose name begins with [x-]: [<x-card name="..." role="..." />] inserts the page fragment generated with these values. Parameters can have a default value: [@props(['name', 'role' => ''])]. The map ([card]) is a component of [Bootstrap].

4.3.5. The pages

{{-- this page EXTENDS the layout: it only provides the content of its sections --}}
@extends('layout')

@section('content')
  <h1>Welcome</h1>
  <p>This page only contains its own content: the header, the navigation
    bar and the footer come from the <code>layout.blade.php</code> layout.</p>
  {{-- the [card] component, with two parameters written on the spot --}}
  <x-card name="A component" role="called with parameters written on the spot" />
@endsection
  • line 2: the page extends the template: [Blade] generates the template page, replacing its [@yield] placeholders with the sections defined by the page. In a view that extends a template, text written outside the sections does not appear in the generated page;
  • Lines 4–10: The section [content], which fills the placeholder of the same name. The page does not define the section [title]: the placeholder retains its default content, “Home”;
  • line 9: the use of the [card] component. The parameters are written as HTML attributes; their values are strings.
@extends('layout')

@section('title', 'Our team')

@section('content')
  <h1>Our team</h1>
  <div class="row g-3">
    @foreach ($members as $member)
      <div class="col-md-4">
        {{-- :name="...": the value of the parameter is a PHP EXPRESSION --}}
        <x-card :name="$member->name" :role="$member->role" />
      </div>
    @endforeach
  </div>
@endsection
  • line 3: a short section, whose content is provided as the second parameter;
  • lines 8–13: one card per team member, in a grid of [Bootstrap] ([row], [col-md-4]: three columns on a medium or large screen, one column on a narrow screen);
  • line 11: a parameter whose name is preceded by [:] is assigned the value of an expression [PHP], not a string: [:name="$member->name"].

The controller does not introduce any new features:

<?php
// app/Http/Controllers/PagesController.php: three pages

namespace App\Http\Controllers;

use App\Models\Member;
use Illuminate\View\View;

class PagesController
{
    public function home(): View
    {
        return view('pages.home');
    }

    public function team(): View
    {
        $members = [
            new Member('Marie Pélissier', 'Director'),
            new Member('Jacques Bromard', 'Developer'),
            new Member('Philippe Jandot', 'Graphic designer'),
        ];
        return view('pages.team', ['members' => $members]);
    }

    public function raw(): View
    {
        return view('pages.raw');
    }
}

Finally, the third page does not use the template:

<!doctype html>
{{-- this view does not use the layout: it is a complete page --}}
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Raw page</title>
</head>
<body>
  <p>This page does not use the layout: no Bootstrap, no navigation bar.</p>
  <p><a href="/">Back to home</a></p>
</body>
</html>

4.3.6. Execution

Image

Image

Image

The team page, as received by the browser (the blank lines left by comments and directives have been removed):

curl http://localhost:8000/team
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Our team - My site</title>
  <link rel="stylesheet" href="http://localhost:8000/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="http://localhost:8000/css/styles.css">
</head>
<body>
  <div class="container py-3">
    <nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">My site</span>
  <div class="navbar-nav">
    <a class="nav-link" href="/">Home</a>
    <a class="nav-link" href="/team">Team</a>
    <a class="nav-link" href="/raw">Page without layout</a>
  </div>
</nav>
    <main>
        <h1>Our team</h1>
  <div class="row g-3">
          <div class="col-md-4">
        <div class="card">
  <div class="card-body">
    <h5 class="card-title">Marie Pélissier</h5>
    <p class="card-text">Director</p>
  </div>
</div>
      </div>
...
      </div>
    </main>
    <footer class="footer">Page [team] - © 2026</footer>
  </div>
</body>
</html>

The browser received a complete page: the template, fragment, page, and components were assembled on the server. The URL values in the style sheets were calculated by [route] and [asset]: these are absolute URL values. The path to the home page is [/]; the paths to other pages are written without a leading slash ([team]): this is the convention for [request()->path()]. You can verify that the style sheets are being applied correctly (the [-I] option of [curl] displays only the response headers):

curl -I http://localhost:8000/bootstrap/css/bootstrap.min.css
curl -I http://localhost:8000/css/styles.css
1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Last-Modified: Sat, 26 Sep 2026 16:04:06 GMT
Content-Length: 232111
Accept-Ranges: bytes
...
HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Content-Length: 435

The first file was sent via our route; the second was sent directly by the development server, without going through [Laravel].

4.4. Example 16: Helpers

A view often needs to format values: an amount with its currency, a date written out in full, or text in uppercase. This code doesn’t belong in the action (which doesn’t handle presentation), and would be cumbersome to write in the view. Instead, it is written in [PHP], in functions that the views call: these are the view’s helpers. [Blade] offers several ways to make them available: calling a function or a static method, writing your own directives ([@money($price)]), and using components, which we’ve already encountered.

16-helpers/
├── app/
│   ├── Helpers/
│   │   └── Formats.php
│   ├── Http/
│   │   ├── Controllers/
│   │   │   └── PagesController.php
│   │   └── Middleware/
│   │       └── SetLanguage.php
│   └── Models/
│       └── Product.php
├── resources/
│   └── views/
│       ├── components/
│       │   ├── badge.blade.php
│       │   └── link.blade.php
│       ├── fragments/
│       │   └── navbar.blade.php
│       ├── layout.blade.php
│       └── pages/
│           ├── home.blade.php
│           └── products.blade.php
└── ...

4.4.1. Functions: app/Helpers/Formats.php

<?php
// app/Helpers/Formats.php: functions called by the views

namespace App\Helpers;

use Brick\Math\BigDecimal;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Number;

class Formats
{
    // "keyboard" -> "KEYBOARD"
    public static function uppercase(?string $text): string
    {
        return $text === null ? '' : mb_strtoupper($text);
    }

    // an amount in a currency, in the format of the language of the request (see the
    // SetLanguage middleware): 1149.9 -> "€1,149.90" in English, "1 149,90 €" in
    // French. PHP's intl extension knows the conventions of each country
    public static function money(BigDecimal $amount, string $currency = 'EUR'): string
    {
        return Number::currency($amount->toFloat(), in: $currency, locale: App::getLocale());
    }

    // a date in full words, in the language of the request: "Saturday 26 September 2026"
    public static function longDate(CarbonInterface $day): string
    {
        return $day->locale(App::getLocale())->isoFormat('dddd D MMMM YYYY');
    }
}
  • Lines 14–17: a static method ([static]): it is called on the class, without an object ([Formats::uppercase('keyboard')]). It accepts [null];
  • lines 22–24: formatting an amount. This depends on the language: in French, [1 149,90 €] (space to separate thousands, decimal comma, symbol after the amount); in English, [€1,149.90]. The class [Number] of [Laravel] is based on the extension [intl] of [PHP], which supports the conventions of all countries (it uses data from the Unicode project *CLDR*): [Number::currency] formats an amount, with the currency designated by its international code ([EUR], [USD]). [intl] can only format numbers [float]: the decimal is converted ([toFloat()]), which is safe here since the number is no longer used for calculations. The language ([fr], [en]) is that of the application for the current query, returned by [App::getLocale()]; we’ll see who sets it;
  • Lines 28–30: Formatting a date [Carbon] (Example 12). [locale(...)] returns a copy of the date set for a specific language; [isoFormat] formats it as follows: [dddd] spells out the day of the week, [D] the day of the month, [MMMM] spells out the month, and [YYYY] the year. [Carbon] comes with translations of these names in over two hundred languages. The [CarbonInterface] type accepts all [Carbon] dates.

4.4.2. The language of each request: app/Http/Middleware/SetLanguage.php

<?php
// app/Http/Middleware/SetLanguage.php: the formatting language of each request

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;

class SetLanguage
{
    // the ?lang=fr parameter of the URL, English otherwise
    public function handle(Request $request, Closure $next): Response
    {
        App::setLocale($request->query('lang') === 'fr' ? 'fr' : 'en');
        return $next($request);
    }
}

The formatting language depends on the request: the [?lang=fr] parameter of URL selects French; English is the default language. A middleware component (example 03) sets it before each action, via [App::setLocale]: the facade [App] refers to the application itself. The language set in this way applies only to the current request: the next request will revert to the language of [.env] ([APP_LOCALE], with English as the default). The chapter on internationalization will use this same language to translate the texts.

4.4.3. Configuration: bootstrap/app.php

<?php
// bootstrap/app.php: the application of example 16

use App\Http\Middleware\SetLanguage;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Blade;

(require __DIR__.'/../../vendor/autoload.php')->addPsr4('App\\', __DIR__.'/../app/');

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        // then: routes declared outside the web group (without session or cookies):
        // the one that serves Bootstrap, shared by examples 15 to 28
        then: fn () => require __DIR__.'/../../common/bootstrap.php')
    ->withMiddleware(function (Middleware $middleware): void {
        // the language of each request (see app/Http/Middleware/SetLanguage.php)
        $middleware->web(append: SetLanguage::class);
    })
    ->withExceptions()
    ->booted(function () {
        // "home-made" DIRECTIVES: @money($price), @longDate($day). A directive
        // receives the TEXT of its parameter, and returns the PHP code that will replace it
        Blade::directive('money', fn (string $expression) =>
            "<?php echo e(\\App\\Helpers\\Formats::money($expression)); ?>");
        Blade::directive('longDate', fn (string $expression) =>
            "<?php echo e(\\App\\Helpers\\Formats::longDate($expression)); ?>");
    })
    ->create();
  • Line 19: The middleware is added to the end of the [web] group: it runs for all routes in [routes/web.php], after the middleware in the group (session, cookies, etc.). Example 03 used [append], which adds a global middleware that runs for all requests, including those from routes outside the [web] group;
  • Lines 25–28: The [Blade] facade registers two “custom” directives: [@money(...)] and [@longDate(...)]. A directive is processed when the view is compiled (Example 13): its function receives the text written between the parentheses (for example, [$product->price, 'USD']) and returns the code [PHP], which will replace the directive in the compiled view. Here, a call is made to the corresponding method of ***[Formats]***, whose result is escaped ([e]***) and written ([echo]***). In a string enclosed in quotes, [\\] refers to a single [\];
  • the directives are stored in a function [booted] (Example 15) once the application has started: the facade [Blade] is only usable at that point.

Because they generate compiled code, directives require a precaution: after modifying a directive, you must clear the compiled views (command [php artisan view:clear]) so that the views are recompiled with the new version.

4.4.4. The controller: app/Http/Controllers/PagesController.php

<?php
// app/Http/Controllers/PagesController.php: two pages

namespace App\Http\Controllers;

use App\Models\Product;
use Brick\Math\BigDecimal;
use Carbon\CarbonImmutable;
use Illuminate\View\View;

class PagesController
{
    public function home(): View
    {
        return view('pages.home', ['today' => CarbonImmutable::today()]);
    }

    public function products(): View
    {
        return view('pages.products', ['products' => [
            new Product('keyboard', BigDecimal::of('25'), 12),
            new Product('mouse', BigDecimal::of('12.5'), 0),
            new Product('screen', BigDecimal::of('1149.9'), 3),
        ]]);
    }
}

Actions pass raw values to the views: a date ([CarbonImmutable::today()], today’s date at midnight) and decimal numbers. Formatting is handled by the views. The [Product] class combines a name, a price (a [BigDecimal], see Example 06), and a stock level. The routes are named [home] and [products].

4.4.5. The views

1
2
3
4
5
6
{{-- a component: a link of the navigation bar, which receives the [active]
     class if the current page is under the [path] path. request(): the
     current request, available in all the views; $slot: the content placed
     between the tags of the component --}}
@props(['href', 'path'])
<a @class(['nav-link', 'active' => request()->is($path)]) href="{{ $href }}">{{ $slot }}</a>
  • line 5: a component that creates a link in the navigation bar. The link for the current page is assigned the class [active] from [Bootstrap], which highlights it;
  • Line 6: The directive [@class] generates the attribute [class] for a tag from an array: a class written on its own (['nav-link']) is always present; a class written as a key (['active' => condition]) is present only if the condition is true. The function [request()] returns the current query; its method [is] compares the path of URL to a pattern, where [*] matches any sequence of characters. The variable [$slot] contains the content between the component’s opening and closing tags ([<x-link ...>Products</x-link>]).
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Helpers</span>
  <div class="navbar-nav">
    {{-- 1. a condition written in the view --}}
    <a class="nav-link{{ request()->is('/') ? ' active' : '' }}" href="/">Home</a>
    {{-- 2. the [link] component --}}
    <x-link href="/products" path="products*">Products</x-link>
    {{-- 3. route() builds the URL of a named route; a parameter that is not a
         parameter of the route is added at the end of the URL: /products?lang=fr --}}
    <a class="nav-link" href="{{ route('products', ['lang' => 'fr']) }}">Produits (fr)</a>
  </div>
</nav>
  • line 5: the same thing, written directly in the view, without a component, using the ternary operator. [request()->is('/')] only recognizes the home page. The component avoids repeating the condition for each link;
  • line 7: using the component;
  • line 10: a parameter from [route] that is not a route parameter is added to URL as a parameter: [/products?lang=fr].
1
2
3
{{-- a Bootstrap badge: green if the product is in stock, red otherwise --}}
@props(['stock'])
<span @class(['badge', 'text-bg-success' => $stock > 0, 'text-bg-danger' => $stock <= 0])>{{ $stock > 0 ? "$stock in stock" : 'sold out' }}</span>
  • Line 3: A badge from [Bootstrap], green ([text-bg-success]) or red ([text-bg-danger]) depending on inventory, using [@class]. The text on the badge is selected by the ternary operator; ["$stock in stock"] is an interpolated string.
@extends('layout')
@section('title', 'Home')

@section('content')
  <h1>Home</h1>
  {{-- the "home-made" @longDate directive --}}
  <p>Today is @longDate($today).</p>
  {{-- app()->getLocale(): the language of the request, set by the SetLanguage middleware --}}
  <p>Formatting language: {{ app()->getLocale() }}
    (<a href="/?lang=fr">in French</a>, <a href="/">in English</a>)</p>
@endsection
  • line 7: the call to the “home” directive;
  • line 9: the function [app()] returns the application, and [getLocale()] sets its language for the current request.
@extends('layout')
{{-- @use: the equivalent, in a view, of PHP's use instruction --}}
@use('App\Helpers\Formats')
@section('title', 'Products')

@section('content')
  <h1>Products</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>Product</th><th>Price</th><th>Price in dollars</th><th>Stock</th></tr></thead>
    <tbody>
      @foreach ($products as $product)
        <tr>
          {{-- a static method, then a directive, without then with a parameter --}}
          <td>{{ Formats::uppercase($product->name) }}</td>
          <td>@money($product->price)</td>
          <td>@money($product->price, 'USD')</td>
          {{-- the [badge] component builds the HTML of the badge --}}
          <td><x-badge :stock="$product->stock" /></td>
        </tr>
      @endforeach
    </tbody>
  </table>
@endsection
  • line 3: the [@use] directive imports a class into the view, just like the [use] statement from a [PHP] file;
  • line 14: a call to a static method, which acts as a function;
  • Lines 15–16: the [@money] directive, first with its default currency, then with a parameter;
  • line 18: the use of the [badge] component.

The [layout.blade.php] template is a simplified version of the one in Example 15.

4.4.6. Execution

Image

Image

The same page, with the parameter [?lang=fr]. Only the formatting of the numbers changes; the text itself is hard-coded in the views. The chapter on internationalization will also translate the text.

Image

An excerpt from HTML produces:

curl "http://localhost:8000/products?lang=en"
1
2
3
4
5
6
7
8
9
...
              <tr>
          <td>KEYBOARD</td>
          <td>€25.00</td>
          <td>$25.00</td>
          <td><span class="badge text-bg-success">12 in stock</span>
</td>
        </tr>
...

4.5. Example 17: A GET form

A HTML form sends its fields to the server in one of two ways, depending on its [method] attribute:

  • [method="get"]: The fields are added to the URL as parameters ([/search?keyword=le&genre=novel]). This is the method used by a form that performs a query: a search or a filter. The URL containing the results can be bookmarked, shared, or reloaded safely;
  • [method="post"]: The fields are included in the request body (see Example 11). This is the method used by a form that modifies data: a registration, an addition, or a deletion.

This first form is a book search.

4.5.1. The model: app/Models/Book.php and SearchModel.php

<?php
// app/Models/Book.php: a book

namespace App\Models;

readonly class Book
{
    public function __construct(
        public string $title,
        public string $author,
        public string $genre,
    ) {
    }
}
<?php
// app/Models/SearchModel.php: the model of the view: the input (to display it
// again), the genres, the results

namespace App\Models;

readonly class SearchModel
{
    /**
     * @param list<string> $genres
     * @param list<Book> $results
     */
    public function __construct(
        public string $keyword,
        public string $genre,
        public array $genres,
        public array $results,
    ) {
    }
}
  • Lines 14–17: The view template contains the user’s input—which the form will display—the list of suggested genres, and the results.

4.5.2. The controller: app/Http/Controllers/BooksController.php

<?php
// app/Http/Controllers/BooksController.php: a search form, sent by GET

namespace App\Http\Controllers;

use App\Models\Book;
use App\Models\SearchModel;
use Illuminate\Http\Request;
use Illuminate\View\View;

class BooksController
{
    // class CONSTANTS: the data, written in the code for the example
    private const GENRES = ['novel', 'poetry', 'drama'];

    /** @return list<Book> */
    private static function books(): array
    {
        return [
            new Book('Le Rouge et le Noir', 'Stendhal', 'novel'),
            new Book('Les Fleurs du mal', 'Baudelaire', 'poetry'),
            new Book('Madame Bovary', 'Flaubert', 'novel'),
            new Book('Alcools', 'Apollinaire', 'poetry'),
            new Book('Le Cid', 'Corneille', 'drama'),
        ];
    }

    // GET / and GET /search?keyword=...&genre=...: the form AND its results
    public function search(Request $request): View
    {
        // string: the parameter, in the form of a string ('' if it is missing)
        $keyword = $request->string('keyword')->value();
        $genre = $request->string('genre')->value();
        $word = mb_strtolower($keyword);
        $results = array_values(array_filter(self::books(), fn (Book $book) =>
            ($word === '' || str_contains(mb_strtolower($book->title), $word)
                || str_contains(mb_strtolower($book->author), $word))
            && ($genre === '' || $book->genre === $genre)));
        return view('books.search', [
            'model' => new SearchModel($keyword, $genre, self::GENRES, $results),
        ]);
    }
}
  • Line 14: a private class constant named [self::GENRES] ([self] refers to the current class). A constant cannot contain objects: therefore, the books are provided by a static method (lines 17–25). In a real-world application, they would come from a database;
  • lines 32–33: the form fields are read as parameters of URL (example 07). The [string] method returns the parameter as a [Stringable] object of type [Laravel] (a string enriched with methods), which is empty if the parameter is missing; [value()] extracts the string [PHP] from it;
  • Lines 35–38: filtering. The function [array_filter] retains the elements of an array for which the given function returns [true]: a book is retained if the keyword is empty or appears in its title or author (in lowercase on both sides), and if the genre is empty or matches its own. [array_filter] retains the keys of the selected elements (0, 2...): [array_values] renumbers them starting from 0;
  • lines 39–41: the view template.

The routing table associates two URL entries with the same action: on the first visit ([/]), there are no parameters, and all books are displayed.

1
2
3
4
5
6
7
8
9
<?php
// routes/web.php: the routes of example 17

use App\Http\Controllers\BooksController;
use Illuminate\Support\Facades\Route;

// two routes for the same action
Route::get('/', [BooksController::class, 'search']);
Route::get('search', [BooksController::class, 'search']);

4.5.3. The view: resources/views/books/search.blade.php

@extends('layout')

@section('content')
  <h1 class="h3">Book search</h1>
  {{-- method="get": the fields are sent in the URL (?keyword=...&genre=...) --}}
  <form method="get" action="/search" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="keyword">Title or author</label>
      {{-- value: the field displays again what was typed (escaped by {{ }}) --}}
      <input class="form-control" id="keyword" name="keyword" value="{{ $model->keyword }}">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(all)</option>
        @foreach ($model->genres as $genre)
          {{-- @selected: the boolean selected attribute, written if the condition is true, omitted otherwise --}}
          <option value="{{ $genre }}" @selected($genre === $model->genre)>{{ $genre }}</option>
        @endforeach
      </select>
    </div>
    <div class="col-auto">
      <button class="btn btn-primary" type="submit">Search</button>
    </div>
  </form>
  <p>{{ count($model->results) }} book(s) found.</p>
  <ul>
    @foreach ($model->results as $book)
      <li><i>{{ $book->title }}</i>, {{ $book->author }} ({{ $book->genre }})</li>
    @endforeach
  </ul>
@endsection
  • Line 6: the [<form>] tag. The [action] attribute is the URL to which the form is submitted; [method="get"] places the fields within the URL. The classes of [Bootstrap] arrange the fields on a single line;
  • Line 8: a label. Its [for] attribute specifies the identifier ([id]) of the field it describes: clicking the label places the cursor in the field, and screen readers read the label along with the field;
  • line 10: an input field. Its attribute [name] is the name of the parameter sent to the server: this is the link between the form and the action ([$request->string('keyword')]). The attribute [value] is the displayed value: the previous entry, redisplayed (and escaped by [{{ }}], which is essential in an attribute: an entry containing a quotation mark ["] would otherwise close the attribute);
  • lines 14–20: a drop-down list. Each [<option>] has a value ([value]), sent to the server, and text, displayed. The first option, with an empty value, means “all genres”;
  • line 18: the option selected in the previous request is assigned the attribute [selected]. This is a Boolean attribute: its presence alone is sufficient; it has no value. The directive [@selected(condition)] sets it if the condition is true, and sets nothing otherwise. Similarly, there are [@checked], [@disabled], [@readonly], and [@required];
  • line 23: the submit button;
  • line 26: the number of results.

4.5.4. Execution

Image

Enter [le] and select the type [novel]:

Image

The URL on the results page contains the form fields: [/search?keyword=le&genre=novel]. The response from the form:

curl "http://localhost:8000/search?keyword=le&genre=novel"
...
  <form method="get" action="/search" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="keyword">Title or author</label>
      <input class="form-control" id="keyword" name="keyword" value="le">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(all)</option>
          <option value="novel" selected>novel</option>
          <option value="poetry" >poetry</option>
          <option value="drama" >drama</option>
              </select>
    </div>
...
  </form>
  <p>1 book(s) found.</p>
  <ul>
          <li><i>Le Rouge et le Noir</i>, Stendhal (novel)</li>
      </ul>
...

“Madame Bovary” is not selected: “le” does not appear in either the title or the author’s name.

4.6. Example 18: Form fields

This example presents all the field types in a HTML form, which is sent in this instance by POST. It demonstrates how to redisplay each field type along with its current value, how the browser sends the values for each field, and how the server verifies that a received value is indeed one of the available options. [Laravel] does not have a library that defines forms using classes (as in [WTForms] and [Python]): the fields are written in HTML within the view, and the directives in [Blade] ([@checked], [@selected]...) help to render them.

4.6.1. The lists service: app/Services/ListsService.php

<?php
// app/Services/ListsService.php: the lists offered by the form. In a
// real application, they would come from a database.

namespace App\Services;

class ListsService
{
    // each list: an associative array sent value => displayed text
    public function titles(): array
    {
        return ['Mr' => 'Mr', 'Mme' => 'Mme', 'Melle' => 'Melle'];
    }

    public function countries(): array
    {
        return ['fr' => 'France', 'be' => 'Belgium', 'ch' => 'Switzerland', 'ca' => 'Canada'];
    }

    public function hobbies(): array
    {
        return ['reading' => 'reading', 'music' => 'music', 'sport' => 'sport',
            'cinema' => 'cinema'];
    }

    public function languages(): array
    {
        return ['French' => 'French', 'English' => 'English', 'Spanish' => 'Spanish',
            'German' => 'German'];
    }
}

The form’s dropdown lists (titles, countries, hobbies, languages) are provided by a service, following the model in Example 06: in a real application, they would come from a database. Each list is an associative array: the key is the value sent to the server, and the value is the text displayed. The service is a concrete class: the container for [Laravel] knows how to create it without a declaration (Example 06).

4.6.2. The controller: app/Http/Controllers/FormController.php

<?php
// app/Http/Controllers/FormController.php: a form that uses all the types of fields

namespace App\Http\Controllers;

use App\Services\ListsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\View\View;

class FormController
{
    // the lists service is injected by the container (see example 06)
    public function __construct(private ListsService $lists)
    {
    }

    // GET: the form, with initial values (one per field)
    public function display(): View
    {
        $input = ['title' => 'Mme', 'country' => 'fr', 'languages' => ['French'], 'token' => 'A7X2'];
        return view('form.form', ['lists' => $this->lists, 'input' => $input]);
    }

    // POST: the sent fields are validated, then displayed again by the form
    public function process(Request $request): View
    {
        $l = $this->lists;
        // array_keys: the allowed VALUES of each list (the keys of the arrays)
        $validator = Validator::make($request->post(), [
            'title' => ['required', Rule::in(array_keys($l->titles()))],
            'birth_date' => 'nullable|date_format:Y-m-d',
            'hobbies' => 'array',
            'hobbies.*' => Rule::in(array_keys($l->hobbies())),
            'country' => ['required', Rule::in(array_keys($l->countries()))],
            'languages' => 'array',
            'languages.*' => Rule::in(array_keys($l->languages())),
        ]);
        // what the browser actually sent, parameter by parameter
        $received = [];
        foreach ($request->post() as $name => $value) {
            $received[] = "$name = [".implode(', ', (array) $value).']';
        }
        return view('form.form', [
            'lists' => $l,
            'input' => $request->post(),
            'received' => implode("\n", $received),
            'valid' => $validator->passes(),
            'errors' => $validator->errors(),
        ]);
    }
}
  • line 15: the service is injected by the constructor;
  • lines 20–23: the initial form values, an associative array: one value per field (an array for a multi-value field). The view receives these values in the variable [$input], along with the list service;
  • lines 31–39: The received fields are validated. Dropdown fields must contain one of the suggested values: a malicious user could send anything, not just what the page suggested. [Rule::in(...)] generates the rule [in] (example 08) from an array of values—in this case, the list keys ([array_keys]). Multiple fields are checked element by element ([hobbies.*], example 10). [date_format:Y-m-d]: A [<input type="date">] field exchanges its values with the server in the [yyyy-mm-dd] format, regardless of the format in which the browser displays it;
  • lines 41–44: for the example, the list of what the browser actually sent. [(array) $value] converts a single value into a one-element array (an array remains an array); [implode] combines the elements of an array into a string, separated by the first parameter;
  • lines 45–51: the form is redisplayed with the received values ([$input]), what the server received, the validation result ([passes()], the opposite of [fails()]), and any errors.

The routing table associates URL and [/form] with action [display] for method GET, and to action [process] for method POST.

4.6.3. The view: resources/views/form/form.blade.php

@extends('layout')

@section('content')
  <h1 class="h3">A complete form</h1>
  <form method="post" action="/form" style="max-width: 40rem">
    {{-- the hidden fields: Laravel's anti-CSRF token, and our [token] field --}}
    @csrf
    <input type="hidden" name="token" value="{{ $input['token'] ?? '' }}">

    <div class="mb-2">
      <span class="form-label me-3">Title</span>
      {{-- radio buttons: same [name], a single value sent --}}
      @foreach ($lists->titles() as $value => $text)
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="radio" id="title-{{ $loop->index }}" name="title"
                 value="{{ $value }}" @checked(($input['title'] ?? '') === $value)>
          <label class="form-check-label" for="title-{{ $loop->index }}">{{ $text }}</label>
        </div>
      @endforeach
    </div>

    <div class="row g-2 mb-2">
      <div class="col">
        <label class="form-label" for="name">Name</label>
        <input class="form-control" type="text" id="name" name="name" value="{{ $input['name'] ?? '' }}">
      </div>
      <div class="col">
        {{-- a password is NEVER sent back in the page: no value attribute --}}
        <label class="form-label" for="password">Password</label>
        <input class="form-control" type="password" id="password" name="password">
      </div>
      <div class="col">
        <label class="form-label" for="birth_date">Date of birth</label>
        <input class="form-control" type="date" id="birth_date" name="birth_date" value="{{ $input['birth_date'] ?? '' }}">
      </div>
    </div>

    <div class="mb-2">
      <span class="form-label me-3">Hobbies</span>
      {{-- checkboxes: the hobbies[] name makes the received value an ARRAY (see example 10) --}}
      @foreach ($lists->hobbies() as $value => $text)
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="checkbox" id="hobbies-{{ $loop->index }}" name="hobbies[]"
                 value="{{ $value }}" @checked(in_array($value, $input['hobbies'] ?? []))>
          <label class="form-check-label" for="hobbies-{{ $loop->index }}">{{ $text }}</label>
        </div>
      @endforeach
    </div>

    <div class="row g-2 mb-2">
      <div class="col">
        <label class="form-label" for="country">Country</label>
        <select class="form-select" id="country" name="country">
          @foreach ($lists->countries() as $value => $text)
            <option value="{{ $value }}" @selected(($input['country'] ?? '') === $value)>{{ $text }}</option>
          @endforeach
        </select>
      </div>
      <div class="col">
        <label class="form-label" for="languages">Spoken languages</label>
        {{-- multiple-selection list: several possible values, hence languages[] --}}
        <select class="form-select" id="languages" name="languages[]" multiple size="4">
          @foreach ($lists->languages() as $value => $text)
            <option value="{{ $value }}" @selected(in_array($value, $input['languages'] ?? []))>{{ $text }}</option>
          @endforeach
        </select>
      </div>
    </div>

    <div class="mb-2">
      <label class="form-label" for="comment">Comment</label>
      {{-- a text area: the value is BETWEEN the tags --}}
      <textarea class="form-control" id="comment" name="comment" rows="2">{{ $input['comment'] ?? '' }}</textarea>
    </div>

    <button class="btn btn-primary" type="submit">Send</button>
  </form>

  {{-- isset: true if the $received variable was passed to the view (by the POST action) --}}
  @isset($received)
    <h2 class="h5 mt-3">What the server received</h2>
    <pre class="bg-light p-2 border">{{ $received }}</pre>
    <p>Valid form: {{ $valid ? 'yes' : 'no' }}</p>
    {{-- $errors->messages(): field -> list of its error messages --}}
    <ul>
      @foreach ($errors->messages() as $field => $messages)
        <li>{{ $field }}: {{ implode(', ', $messages) }}</li>
      @endforeach
    </ul>
  @endisset
@endsection
  • line 5: a form named POST;
  • line 7: the [@csrf] directive writes a hidden field named [_token], which contains the anti-CSRF token. A CSRF attack (*Cross-Site Request Forgery*) involves a malicious site tricking the victim’s browser into sending a form to our application. The browser includes our application’s cookies in the request—and thus the logged-in user’s session—making the request appear legitimate. The defense: every form in our application contains a random token linked to the user’s session, which the malicious site cannot know. The [PreventRequestForgery] middleware in the [web] group (Example 01) rejects any POST request (as well as PUT, PATCH, and DELETE) whose token is missing or invalid, even before the action is called: no action can omit it. This is the one that Examples 02 and 11 excluded for their [curl] requests;
  • line 8: our own hidden field;
  • lines 13–19: a group of radio buttons, one per title. All have the same attribute [name]: only one is selected at a time, and only one value is sent. Each button has its own identifier ([title-0], [title-1]...), generated using [$loop->index], to which its label refers. The [@checked] directive checks the button whose value matches the current title;
  • line 25: the name input field; [?? ''] handles the case where a name is not in the table;
  • Line 30: A password should never be sent back and forth within a page HTML: The field has no attribute [value]; it is always empty when displayed;
  • Lines 41–47: Checkboxes, styled like radio buttons. They also share the same name; however, multiple checkboxes can be selected, and the browser then sends multiple values: the name [hobbies[]] instructs [PHP] to organize them into an array (Example 10). The function [in_array] checks whether a value is present in an array;
  • lines 53–57: a drop-down list, whose current option is marked by [@selected];
  • line 62: a multi-select list (attribute [multiple]), displayed over four lines ([size]), whose name also ends with [[]];
  • line 73: a text field spanning two lines. Its value is entered between its tags, not in an attribute;
  • Lines 80–90: The directive [@isset] checks for the existence of a variable: [$received] was passed to the view only by the action POST. [$errors->messages()] returns, for each invalid field, a list of its error messages (example 09).

4.6.4. Execution

Image

The default values are visible: the “Mme” radio button, the country “France,” and the language “French.” HTML produces (excerpts):

<input type="hidden" name="_token" value="mjFN6z3WBF79ZEA8EWgmoX3Hio8DjjmG7hGQlGax" autocomplete="off">
<input type="hidden" name="token" value="A7X2">
...
<input class="form-check-input" type="radio" id="title-1" name="title"
       value="Mme" checked>
<label class="form-check-label" for="title-1">Mme</label>
...
<input class="form-control" type="date" id="birth_date" name="birth_date" value="">
...
<input class="form-check-input" type="checkbox" id="hobbies-0" name="hobbies[]"
       value="reading" >
<label class="form-check-label" for="hobbies-0">reading</label>
...
<select class="form-select" id="country" name="country">
  <option value="fr" selected>France</option>
  <option value="be" >Belgium</option>
...
<select class="form-select" id="languages" name="languages[]" multiple size="4">
  <option value="French" selected>French</option>
...
<textarea class="form-control" id="comment" name="comment" rows="2"></textarea>

The anti-CSRF token is a random 40-character string, stored in the user’s session (example 20). Let’s fill out the form and submit it:

Image

The page shows what the server received: one parameter per field, with multiple values for the checkboxes and the dropdown list; the password was sent but is not displayed again in its field. The hidden field [token] was returned exactly as it was: this is how to send and receive information that the user cannot see (the case study uses this for a record’s version number). However, a hidden field is not a protected field: the user can modify it using their browser’s developer tools, and the server must validate it just like any other field. We also note that the anti-CSRF token ([_token]) is among the received fields.

Now let’s submit a form without a token, using [curl]:

curl -i -d "name=Martin&country=xx" http://localhost:8000/form
1
2
3
4
5
6
7
8
HTTP/1.1 419 unknown status
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html lang="en">
...
        <title>Page Expired</title>
...

The request is rejected by the [PreventRequestForgery] middleware with status code 419, specific to [Laravel] (*Page Expired*: this is the message seen by a user whose session expired between the time the form was displayed and the time it was submitted). To resolve this, [curl] must behave like a browser: first request the form, while retaining the session cookie (option [-c], which saves received cookies to a file), then resend the cookie (option [-b]) and the token read from the page:

curl -c cookies.txt http://localhost:8000/form
curl -b cookies.txt -d "_token=WkdoQuahsZxMScUg5UJ5UOwWN4nDF8l3Niyce9WK&name=Martin&country=xx" http://localhost:8000/form
...
      <h2 class="h5 mt-3">What the server received</h2>
    <pre class="bg-light p-2 border">_token = [WkdoQuahsZxMScUg5UJ5UOwWN4nDF8l3Niyce9WK]
name = [Martin]
country = [xx]</pre>
    <p>Valid form: no</p>
    <ul>
              <li>title: The title field is required.</li>
              <li>country: The selected country is invalid.</li>
          </ul>
...

(the token is the one from the page received by the first command). Two errors: the country [xx] is not among the options; no title was sent. The messages are Laravel's default ones: the following example customizes them.

4.7. Example 19: Form Validation

A registration form, where each field has its own validation rules. If an error occurs, the form is redisplayed with the user’s input and a message below each invalid field. This example uses the standard approach of [Laravel]: a form request (Example 08) validates the fields before the action; if an error occurs, [Laravel] redirects to the form page, preserving the user’s input and the errors for the next request.

4.7.1. The form: app/Http/Requests/RegistrationRequest.php

<?php
// app/Http/Requests/RegistrationRequest.php: the fields of the form and their constraints

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class RegistrationRequest extends FormRequest
{
    // the rules of each field. The spaces that surround the input have already been
    // removed by the TrimStrings middleware, and an empty input replaced by null
    public function rules(): array
    {
        return [
            'name' => 'required|max:20',
            // email:filter: the check of PHP's filter_var function, which
            // requires a complete domain name (example.com, and not example)
            'email' => 'required|email:filter',
            'age' => 'required|integer|between:18,120',
            'password' => 'required|min:6',
            // a rule about TWO fields: same compares this field with another one
            'confirmation' => 'required|same:password',
        ];
    }

    // the error messages, field.rule => message
    public function messages(): array
    {
        return [
            'name.required' => 'The name is required.',
            'name.max' => 'The name must not exceed 20 characters.',
            'email.required' => 'The email address is required.',
            'email.email' => 'Invalid email address.',
            'age.required' => 'The age is required.',
            'age.between' => 'The age must be between 18 and 120 years.',
            'password.required' => 'The password is required.',
            'password.min' => 'The password must have at least 6 characters.',
            'confirmation.required' => 'The confirmation is required.',
            'confirmation.same' => 'The confirmation does not match the password.',
        ];
    }
}
  • Lines 12–23: the validation rules for each field (Example 08). The spaces surrounding the input have already been removed by the [TrimStrings] middleware (example 08): a name consisting of spaces becomes an empty string, then [null], and will be rejected by [required];
  • Line 18: The [email] rule accepts variants, which determine which validation is applied. By default, it follows the email address standard, which accepts [martin@example] (a hostname without a domain). [email:filter] uses the [filter_var] function from [PHP] (example 07), which requires a fully qualified domain name ([example.com]);
  • line 19: [integer] followed by [between:18,120];
  • Line 22: A rule that applies to two fields: [same:password] compares the value of one field to that of another field. [Laravel] also provides the rule [confirmed], which, when applied to the field [password], would require a field named [password_confirmation];
  • Lines 27–40: The method [messages] provides messages specific to this form. Each key designates a field and a rule ([name.required]); a single key such as [required] would apply to all fields. These messages take precedence over those in the language files (example 09).

Messages not provided by the class come from the file [lang/en/validation.php] (the file [.env] sets the language to [en]; see example 09):

1
2
3
4
5
6
<?php
// lang/en/validation.php: the messages that the RegistrationRequest class does not
// provide itself
return [
    'integer' => 'An integer is expected.',
];

This is the case for the message from rule [integer], which is displayed only if something other than a number is entered in the age field—something a browser does not do (we’ll see this with [curl]).

4.7.2. The controller: app/Http/Controllers/RegistrationController.php

<?php
// app/Http/Controllers/RegistrationController.php: a form validated by the server

namespace App\Http\Controllers;

use App\Http\Requests\RegistrationRequest;
use Illuminate\Http\Request;
use Illuminate\View\View;

class RegistrationController
{
    // GET /register: the form; ?html5=true: we let the browser
    // validate it before sending
    public function display(Request $request): View
    {
        return view('registration.registration', ['html5' => $request->query('html5') === 'true']);
    }

    // POST /register: the action is called only if the form is VALID.
    // Otherwise, Laravel REDIRECTS to the page of the form, keeping the input and
    // the errors for the next request (see old and @error in the views)
    public function register(RegistrationRequest $request): View
    {
        return view('registration.welcome', ['registration' => $request->validated()]);
    }
}
  • lines 14–17: the form display. The parameter [?html5=true] enables browser validation (see below);
  • lines 22–25: the action only accepts valid forms: the form request [RegistrationRequest] was validated before it was called (example 08). It displays the welcome page with the validated data. (The following example will show that it is better to redirect after a successful POST.)

What about an invalid form? It never reaches the action. The form request triggers a [ValidationException] (example 08), which [Laravel] handles as follows for a request coming from a browser: it stores the errors and the entered values (except passwords) in the user’s session, then responds with a redirect to the previous page—the form page. The browser immediately requests this page again (via a GET), and the form view retrieves the errors and user input, which the session provides only once (we will see this “flash” data mechanism in the next example). The form is thus redisplayed via its GET action, rather than via the POST action: this is already the Post/Redirect/Get pattern from the following example, applied to errors.

4.7.3. A reusable field: resources/views/components/field.blade.php

Each field is displayed in the same way: a label, the input field, and, in the event of an error, the area outlined in red and the message below it. A component prevents you from having to write this five times:

{{-- an input field, with its label and its FIRST error message.
     old($name): the previous input, kept by Laravel after an invalid
     form; $attributes: the other attributes given to the component (required,
     min, max...), copied onto the input tag --}}
@props(['name', 'label', 'type' => 'text'])
<div class="mb-2">
  <label class="form-label" for="{{ $name }}">{{ $label }}</label>
  <input {{ $attributes->class(['form-control', 'is-invalid' => $errors->has($name)]) }}
         type="{{ $type }}" id="{{ $name }}" name="{{ $name }}"
         @if ($type !== 'password') value="{{ old($name) }}" @endif>
  {{-- @error: the block is displayed only if the field has an error, whose
       first message is in the $message variable --}}
  @error($name)
    <div class="invalid-feedback">{{ $message }}</div>
  @enderror
</div>
  • line 5: the component’s parameters: the field name, its label, and its type, [text] by default;
  • line 8: the variable [$attributes] contains the attributes assigned to the component that are not parameters declared by [@props] (here, [required], [min], [max]...): when written in the tag, it copies them over. Its method [class] adds the attribute [class] to it, similar to the directive [@class] (Example 16): The [is-invalid] class of [Bootstrap] outlines the field in red if the field contains an error. The variable [$errors] is available in all views: it is a “bag” of messages (Example 09)—containing errors from the previous form stored in the session—or an empty bag; [has] checks for errors in a field;
  • Line 10: The function [old] returns the value entered in the previous form, stored in the session, or [null]. A field of type [password] does not have the [value] attribute (example 18);
  • Lines 13–15: The block [@error] ... [@enderror] is displayed only if the field contains an error; in that case, its first message is stored in the variable [$message]. The element [invalid-feedback] is displayed in red by [Bootstrap], below a zone labeled [is-invalid].

4.7.4. The views

@extends('layout')

@section('content')
  <h1 class="h3">Registration</h1>
  @if ($html5)
    <p>Browser validation <b>enabled</b> - <a href="/register">disable it</a></p>
  @else
    <p>Browser validation <b>disabled</b> - <a href="/register?html5=true">enable it</a></p>
  @endif
  {{-- novalidate: the browser ignores the required, min... attributes and
       sends the form as is; only the server validates --}}
  <form method="post" action="/register" @if (! $html5) novalidate @endif>
    @csrf
    <x-field name="name" label="Name" required maxlength="20" />
    <x-field name="email" label="Email address" type="email" required />
    <x-field name="age" label="Age" type="number" required min="18" max="120" />
    <x-field name="password" label="Password" type="password" required minlength="6" />
    <x-field name="confirmation" label="Confirmation" type="password" required />
    <button class="btn btn-primary" type="submit">Register</button>
  </form>
@endsection
  • line 12: the [novalidate] attribute disables browser validation: the form is submitted as-is, and only the server validates it. This is what we want to observe first;
  • lines 14–18: one component per field. The attributes HTML5 ([required], [maxlength], [min], [max], [minlength], [type="email"]) allow the browser to verify the input even before it is submitted (we’ll cover this later). [Laravel] does not derive these from the validation rules: they are written in the view and must remain consistent with the rules.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Registration</title>
  <link rel="stylesheet" href="{{ route('bootstrap', 'css/bootstrap.min.css') }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    <main>
      @yield('content')
    </main>
    {{-- the global error, at the bottom of the page, on a light pink background. $errors: the errors
         of the previous form, available in all the views --}}
    @if ($errors->any())
      <div class="error-area" role="alert">The form contains errors.</div>
    @endif
  </div>
</body>
</html>
  • lines 16–18: a general message at the bottom of the page if the previous form contained errors ([any()] checks whether the bag contains at least one message), in an area whose style is defined by [public/css/styles.css] (example 15). The [role="alert"] attribute flags this for screen readers.
1
2
3
4
5
6
7
8
@extends('layout')

@section('content')
  {{-- $registration: the validated data --}}
  <h1 class="h3">Welcome {{ $registration['name'] }}</h1>
  <p>Your registration is saved: address <b>{{ $registration['email'] }}</b>,
    age <b>{{ $registration['age'] }}</b> years.</p>
@endsection

[$registration] contains the validated data: the name with spaces removed, the age (a string; see Example 08).

4.7.5. Execution

Let’s submit a form full of errors: a name consisting of spaces, an email address without a full domain, an age of 12, a password that’s too short, and a different confirmation:

Image

The input was preserved (except for the passwords), and each error is flagged below its corresponding field. URL is the form’s token, and [/register] indicates that the page was displayed by the action GET after the redirect. With [curl], by providing the anti-CSRF token as in Example 18, we see this redirection:

curl -i -b cookies.txt -c cookies.txt -d "_token=...&name=Martin&email=martin@example.com&age=abc&password=abcdef&confirmation=abcdef" http://localhost:8000/register
1
2
3
4
HTTP/1.1 302 Found
Location: http://localhost:8000/register
Content-Type: text/html; charset=utf-8
...

You can also send something a browser wouldn’t send, such as a non-numeric age. The following page ([curl -b cookies.txt http://localhost:8000/register]) shows the age field, with its input and the message from rule [integer], taken from the language file:

1
2
3
4
  <input class="form-control is-invalid" required="required" min="18" max="120"
         type="number" id="age" name="age"
          value="abc" >
      <div class="invalid-feedback">An integer is expected.</div>

We also see the attributes HTML5 copied by [$attributes]: [required] (written as [required="required"], an equivalent form), [min], [max].

4.7.6. Browser validation

Let’s remove the [novalidate] attribute by following the “Enable” link ([?html5=true] setting). The browser then checks the attributes [required], [type="email"], [min], [maxlength]... before submitting the form, and displays its own messages in a tooltip below the first invalid field (“Please fill in this field,” “The value must be greater than or equal to 18,” etc.): the form is not submitted as long as there is an error.

This validation is convenient for the user: it’s immediate, with no back-and-forth with the server. But it does not replace server-side validation: a user can disable it, and a program like [curl] ignores it completely. The rule is absolute: the server always validates; browser-side validation is merely an added convenience.

A correct entry, at last. The name, entered with spaces on either side, was cleaned up by the [TrimStrings] middleware:

Image

4.8. Example 20: The Post/Redirect/Get pattern

When a POST action returns a page, that page is the result of a POST. If the user refreshes the page (by pressing [F5]), the browser sends the POST request, following a warning that few people read: the command is sent twice, and the message is posted twice. The Post/Redirect/Get pattern (PRG) avoids this problem: a successful POST action does not return a page; it responds with a redirect (example 04) to a page GET, which the browser requests immediately. Reloading this page simply re-executes the GET action.

One problem remains: the GET page would like to display a message (“the task has been added”), but it is the POST action that knows this message, and it cannot pass a variable to a request that has not yet occurred. The solution: the flash message, stored in the session by the action POST, is retained until the next request, displayed, and then discarded. This is the mechanism that [Laravel] was already using, in Example 19, for form errors.

4.8.1. The service: app/Services/TasksService.php

<?php
// app/Services/TasksService.php: the list of the tasks, shared by all the
// users. In PHP, the objects do not survive the request: the list is
// stored in Laravel's CACHE (here, a file of the storage/framework/cache folder)

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class TasksService
{
    private const KEY = 'tasks';

    /** @return list<string> */
    public function list(): array
    {
        // the value stored under the [tasks] key, or the initial list
        return Cache::get(self::KEY, ['Read chapter 3']);
    }

    public function add(string $label): void
    {
        // several requests can run AT THE SAME TIME (the server handles
        // them in several processes): a lock guarantees that only one at a
        // time reads, modifies and rewrites the list. block(5, ...) waits for the lock 5
        // seconds at most, runs the function, then releases the lock
        Cache::lock('tasks-lock', 10)->block(5, function () use ($label) {
            $tasks = $this->list();
            $tasks[] = $label;
            // forever: the value is kept without any time limit
            Cache::forever(self::KEY, $tasks);
        });
    }
}

The task list is shared by all users and must persist from one request to the next. In [Java], [C#], or [Python], it would be kept in memory within a single service. In [PHP], this is impossible: as we saw in examples 01 and 06, nothing survives the query. The list must therefore be stored outside of [PHP]: in a database, a file... or in the cache of [Laravel], which we are using here.

  • Line 18: The [Cache] interface provides access to the application’s cache: a store of key→value pairs. The [.env] file in the examples stores this data in files within the [storage/framework/cache] folder ([CACHE_STORE=file], Example 01); in production, a dedicated server, such as [Redis], is preferred. [get] returns the value stored under a key, or the default value provided as the second parameter. Any value (in this case, an array of strings) can be stored: [Laravel] serializes it—that is, it converts it to text—using the [serialize] function from [PHP];
  • Lines 27–32: The development server, like a production server, can process multiple requests at the same time, in multiple processes (the environment variable [PHP_CLI_SERVER_WORKERS] sets the number of processes for [php artisan serve]). Two simultaneous inserts could read the same list, each add its own task to it, and then write it back: the second write would overwrite the first task. A lock ensures that only one request at a time executes the code it protects: [Cache::lock('tasks-lock', 10)] creates a named lock—which is also stored in the cache—that expires after no more than 10 seconds (if the request holding it were to abruptly terminate); [block(5, function)] waits up to 5 seconds for the lock to become available, acquires it, executes the function, and then releases it. The keyword [use] in an anonymous function gives it access to a variable in the containing function (here, [$label]); an arrow function, on the other hand, automatically sees these variables;
  • Line 31: [forever] stores a value indefinitely; [put(key, value, seconds)] would store it for a specified duration.

4.8.2. The controller: app/Http/Controllers/TasksController.php

<?php
// app/Http/Controllers/TasksController.php: the Post / Redirect / Get pattern

namespace App\Http\Controllers;

use App\Services\TasksService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class TasksController
{
    public function __construct(private TasksService $tasks)
    {
    }

    // GET /tasks: the list and the add form
    public function display(): View
    {
        return view('tasks.tasks', ['tasks' => $this->tasks->list()]);
    }

    // POST /tasks: the Post / Redirect / Get pattern
    public function add(Request $request): View|RedirectResponse
    {
        $label = $request->string('label')->value();
        if ($label === '' || mb_strlen($label) > 40) {
            // invalid input: we display the page again, without a redirect
            return view('tasks.tasks', ['tasks' => $this->tasks->list(),
                'label' => $label,
                'error' => 'The label must have 1 to 40 characters.']);
        }
        $this->tasks->add($label);
        // no page: a redirect to the GET page; with leaves a "flash" message
        // in the session: it will survive the redirect, for the NEXT request
        return redirect()->route('tasks')->with('message', "The task [$label] has been added.");
    }

    // POST /tasks/without-prg: the counter-example (a page in response to the POST)
    public function addWithoutPrg(Request $request): View
    {
        $this->tasks->add($request->string('label')->value() ?: '(untitled)');
        return view('tasks.tasks', ['tasks' => $this->tasks->list(), 'withoutPrg' => true]);
    }
}
  • lines 26–31: The input is validated manually to keep the example simple. An invalid entry is displayed again, without redirection: there is nothing to protect against a duplicate submission, since nothing has been modified. [mb_strlen] counts the characters in a string UTF-8;
  • line 36: the redirect to the page GET, named [tasks]. The [with] method of a redirect places a flash message in the session: data retained only for the next request;
  • lines 40–43: the counterexample: a page in response to POST. The operator [?:] returns its left operand if it is “true” (here, a non-empty string), and the right operand otherwise.

The routing table:

<?php
// routes/web.php: the routes of example 20

use App\Http\Controllers\TasksController;
use Illuminate\Support\Facades\Route;

Route::controller(TasksController::class)->group(function () {
    Route::get('tasks', 'display')->name('tasks');
    Route::post('tasks', 'add');
    Route::post('tasks/without-prg', 'addWithoutPrg');
});

4.8.3. The views

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Tasks</title>
  <link rel="stylesheet" href="{{ route('bootstrap', 'css/bootstrap.min.css') }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    {{-- the flash message, at the top of the page: left BEFORE the redirect; the session
         returns it during the next request, then forgets it --}}
    @session('message')
      <div class="alert alert-success" role="status">{{ $value }}</div>
    @endsession
    <main>
      @yield('content')
    </main>
    @isset($error)
      <div class="error-area" role="alert">{{ $error }}</div>
    @endisset
  </div>
</body>
</html>
  • Lines 13–15: The block [@session('message')] ... [@endsession] is displayed only if the session contains a [message] entry, in which case its value is stored in the variable [$value]. The class [alert alert-success] of [Bootstrap] displays the message in a green frame.
@extends('layout')

@section('content')
  <h1 class="h3">My tasks</h1>
  <ol>
    @foreach ($tasks as $task)
      <li>{{ $task }}</li>
    @endforeach
  </ol>
  <form method="post" action="/tasks" class="d-flex gap-2">
    @csrf
    <input class="form-control" name="label" value="{{ $label ?? '' }}" placeholder="New task">
    <button class="btn btn-primary text-nowrap" type="submit">Add</button>
    {{-- formaction: this button sends the SAME form to another address --}}
    <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="/tasks/without-prg">Add without PRG</button>
  </form>
  @isset($withoutPrg)
    <p class="text-danger mt-2">Page displayed in response to a POST: press F5 and the task will be added a second time.</p>
  @endisset
@endsection
  • Line 15: The [formaction] attribute of a button submits the form to an address other than the one specified by its [action] attribute: a single form can thus trigger two different actions.

4.8.4. Where are flash messages stored?

Between the POST request and the subsequent GET request, the message must be stored somewhere: in the user’s session. The [Laravel] session is stored on the server: the [.env] file in the examples stores it in a file within the [storage/framework/sessions] folder ([SESSION_DRIVER=file]), with one file per user. The browser, on the other hand, receives only a session ID in the [example20_session] cookie (the name is derived from [APP_NAME]), which it sends back with every request. Let’s examine this using [curl], while storing the cookies in a file (options [-c] and [-b], Example 18), and providing the anti-CSRF token:

curl -i -b cookies.txt -c cookies.txt -d "_token=...&label=Do%20the%20exercises" http://localhost:8000/tasks
1
2
3
4
5
6
HTTP/1.1 302 Found
Location: http://localhost:8000/tasks
Content-Type: text/html; charset=utf-8
Set-Cookie: XSRF-TOKEN=eyJpdiI6IjFJLzhDVy9CWndjODZNb2s3N2VMUnc9PSIsInZhbHVlIjoiT3JXZy96MmJROHUrYmZaNUFNbUFoM3d4...; expires=...; Max-Age=7200; path=/; samesite=lax
Set-Cookie: example20_session=eyJpdiI6IlMwK3R3ZkQ2TEhKUVZ4SW9saDVMNUE9PSIsInZhbHVlIjoiMVVnUTNvTHFL...; expires=...; Max-Age=7200; path=/; httponly; samesite=lax
...

The response to POST is a redirect (status code 302, header [Location]), accompanied by the session cookie. Its value is encrypted with the key [APP_KEY] (this is the role of the [EncryptCookies] middleware in the [web] group): the user cannot read or modify it. When decrypted, it contains only the session ID—40 random characters—which is also the name of the session file on the server: [storage/framework/sessions/HNuWctWJ0HcPvmsAhFN5v3iCiw1SFFDMXv46F6og]. Here is that file, following the request POST (displayed across multiple lines):

1
2
3
4
a:4:{s:6:"_token";s:40:"X0omnUmKl13zVLUrzTdZy5mWN7R6fBaiOmfEUPpr";
     s:9:"_previous";a:2:{s:3:"url";s:27:"http://localhost:8000/tasks";s:5:"route";s:5:"tasks";}
     s:6:"_flash";a:2:{s:3:"old";a:1:{i:0;s:7:"message";}s:3:"new";a:0:{}}
     s:7:"message";s:43:"The task [Do the exercises] has been added.";}

This is a serialized [PHP] array: [a:4] indicates an array of 4 elements, [s:6:"_token"] a 6-byte string... It contains the session’s anti-CSRF token, the URL from the previous page ([_previous], which is used by the redirect to the previous page in Example 19), our message, and the [_flash] list of flash data. At the end of each request, the flash data set during the request (list [new]) is moved to the list [old], and the data that was already in [old] is removed: thus, a piece of flash data survives for exactly one request. Let’s follow the redirection by sending the cookie back:

curl -i -b cookies.txt -c cookies.txt http://localhost:8000/tasks
1
2
3
4
5
6
7
HTTP/1.1 200 OK
...
          <div class="alert alert-success" role="status">The task [Do the exercises] has been added.</div>
...
          <li>Read chapter 3</li>
          <li>Do the exercises</li>
...

The message is displayed, and after this request, the session file no longer contains it:

1
2
3
a:3:{s:6:"_token";s:40:"X0omnUmKl13zVLUrzTdZy5mWN7R6fBaiOmfEUPpr";
     s:9:"_previous";a:2:{s:3:"url";s:27:"http://localhost:8000/tasks";s:5:"route";s:5:"tasks";}
     s:6:"_flash";a:2:{s:3:"old";a:0:{}s:3:"new";a:0:{}}}

A new request, GET, would no longer display the message. The task list, however, is stored in a cache file whose name is calculated based on the key ([storage/framework/cache/data/55/d8/55d8727a05eb80b0788af57a5317f3e0a1f4aa55]):

9999999999a:2:{i:0;s:14:"Read chapter 3";i:1;s:16:"Do the exercises";}

The first ten digits represent the value’s expiration date (in this case, never); the rest is the serialized list.

4.8.5. Execution

Adding a task: the browser followed the redirect; URL corresponds to the page GET, which can be safely reloaded.

Image

An invalid entry, redisplayed with its error message:

Image

The counterexample: the page is the response to POST, and its URL is [/tasks/without-prg]. If you click on [F5], the browser asks if you want to resubmit the form; if you agree, the task is added a second time.

Image

A principle of the HTTP protocol: a GET request does not change anything. Browsers, search engines, and tools that preload pages freely follow links without asking the user for permission. A “delete” link ([<a href="/contacts/2/delete">]) would therefore be dangerous: a bot crawling the site would delete everything. In a web application:

  • viewing is done via a link ([<a href>]), which sends a GET request;
  • editing (save, duplicate, delete) is done via a button placed in a form, protected by an anti-CSRF token.

This example manages a contact list and displays a confirmation window before each deletion.

4.9.1. The service: app/Services/ContactsService.php

<?php
// app/Services/ContactsService.php: the contacts, shared by all, stored in
// Laravel's cache (see example 20)

namespace App\Services;

use App\Models\Contact;
use Illuminate\Support\Facades\Cache;

class ContactsService
{
    private const KEY = 'contacts';

    /** @return array{contacts: list<Contact>, lastId: int} */
    private function data(): array
    {
        return Cache::get(self::KEY, [
            'contacts' => [
                new Contact(1, 'Marie Durand', '01 23 45 67 89'),
                new Contact(2, 'Paul Martin', '06 12 34 56 78'),
                new Contact(3, 'Philippe Jandot', '02 98 76 54 32'),
            ],
            'lastId' => 3,
        ]);
    }

    // a modification of the data, under lock: the function receives the data,
    // modifies them (&), and returns the result of the operation
    private function editData(callable $operation): mixed
    {
        return Cache::lock('contacts-lock', 10)->block(5, function () use ($operation) {
            $data = $this->data();
            $result = $operation($data);
            Cache::forever(self::KEY, $data);
            return $result;
        });
    }

    /** @return list<Contact> */
    public function list(): array
    {
        return $this->data()['contacts'];
    }

    // the contact, or null if it does not exist
    public function read(int $id): ?Contact
    {
        return array_find($this->list(), fn (Contact $c) => $c->id === $id);
    }

    public function edit(int $id, string $name, string $phone): ?Contact
    {
        return $this->editData(function (array &$data) use ($id, $name, $phone) {
            foreach ($data['contacts'] as $i => $contact) {
                if ($contact->id === $id) {
                    // a contact is immutable (readonly): we build a new one,
                    // which replaces the old one in the array
                    return $data['contacts'][$i] = new Contact($id, $name, $phone);
                }
            }
            return null;
        });
    }

    public function add(string $name, string $phone): Contact
    {
        return $this->editData(function (array &$data) use ($name, $phone) {
            $contact = new Contact(++$data['lastId'], $name, $phone);
            $data['contacts'][] = $contact;
            return $contact;
        });
    }

    public function delete(int $id): ?Contact
    {
        return $this->editData(function (array &$data) use ($id) {
            foreach ($data['contacts'] as $i => $contact) {
                if ($contact->id === $id) {
                    // array_splice removes item i from the array, and renumbers the following ones
                    array_splice($data['contacts'], $i, 1);
                    return $contact;
                }
            }
            return null;
        });
    }
}

Contacts are stored in the cache, just like the tasks in Example 20. Each contact is an immutable [Contact] object (an ID, a name, a phone number), stored in an array with the most recently assigned ID. A few points about [PHP]:

  • Lines 29–37: All modifications follow the same pattern: acquire the lock, read the data, modify it, write it back, and release the lock. This method writes to the array once; each modification passes a function (of type [callable], “callable”) to it, which receives the data and modifies it. The function must modify the array itself, not a copy: its parameter is declared as [array &$data], where [&] specifies a pass-by-reference (without [&], an array is copied; see Example 06);
  • lines 51–63: A contact is immutable: a new one is created, which replaces the old one in the array. An assignment is an expression whose value is the assigned value: [return $data['contacts'][$i] = new Contact(...)] replaces the contact and returns it;
  • line 68: [++] placed before a variable increments it by 1 and returns the new value;
  • line 80: [array_splice] removes an element from the array and renumbers the subsequent elements;
  • The methods return [null] for a nonexistent contact: it is up to the controller to decide on the response.

4.9.2. The controller: app/Http/Controllers/ContactsController.php

<?php
// app/Http/Controllers/ContactsController.php: links (GET) and buttons (POST, DELETE)

namespace App\Http\Controllers;

use App\Models\Contact;
use App\Services\ContactsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class ContactsController
{
    public function __construct(private ContactsService $contacts)
    {
    }

    // the contact, or a 404 response if it does not exist
    private static function contactOr404(?Contact $contact): Contact
    {
        abort_if($contact === null, 404, 'unknown contact');
        return $contact;
    }

    // a LINK (<a href>): a GET request, which modifies nothing
    public function list(): View
    {
        return view('contacts.list', ['contacts' => $this->contacts->list()]);
    }

    public function details(int $id): View
    {
        return view('contacts.details', ['contact' => self::contactOr404($this->contacts->read($id))]);
    }

    // a BUTTON in a POST form: a request that modifies data
    public function save(Request $request, int $id): RedirectResponse
    {
        $contact = self::contactOr404($this->contacts->edit($id,
            $request->string('name')->value(), $request->string('phone')->value()));
        return redirect()->route('contacts')->with('message', "The contact [$contact->name] has been edited.");
    }

    // same form, another button (formaction attribute): another action
    public function duplicate(Request $request, int $id): RedirectResponse
    {
        $copy = $this->contacts->add($request->string('name')->value().' (copy)',
            $request->string('phone')->value());
        return redirect()->route('contacts')->with('message', "The contact [$copy->name] has been created.");
    }

    // a DELETE request, simulated by a POST form (see @method in the view)
    public function delete(int $id): RedirectResponse
    {
        $contact = self::contactOr404($this->contacts->delete($id));
        return redirect()->route('contacts')->with('message', "The contact [$contact->name] has been deleted.");
    }
}
  • lines 19–23: a utility function: a nonexistent contact terminates the action with a 404 response ([abort_if], see Example 15);
  • lines 26–34: the two actions GET, which do not modify anything: the list and a contact’s record;
  • lines 37–42: saving the record: an action POST, which follows the schema PRG. The same URL and [/contacts/2] are handled by two different actions, depending on the method: GET displays, POST saves;
  • Lines 45–50: duplication—another action, POST, triggered by a second button on the same form;
  • Lines 53–57: Deletion, handled by a query DELETE.

The routing table:

<?php
// routes/web.php: the routes of example 21

use App\Http\Controllers\ContactsController;
use Illuminate\Support\Facades\Route;

Route::prefix('contacts')->controller(ContactsController::class)->group(function () {
    Route::get('/', 'list')->name('contacts');
    Route::get('{id}', 'details')->whereNumber('id')->name('contacts.details');
    Route::post('{id}', 'save')->whereNumber('id')->name('contacts.save');
    Route::post('{id}/duplicate', 'duplicate')->whereNumber('id')->name('contacts.duplicate');
    Route::delete('{id}', 'delete')->whereNumber('id')->name('contacts.delete');
});
  • Line 12: Deletion is declared for the HTTP method DELETE, which the HTTP protocol specifies for deleting a resource. However, a HTML form can only send GET and POST requests. [Laravel] circumvents this limitation: a POST form containing a hidden field [_method], with a value of [DELETE] (or [PUT], [PATCH]), is treated as a request for that method. This is method spoofing, which is implemented by the [@method] directive in views.

4.9.3. The views

@extends('layout')

@section('content')
  <h1 class="h3">Contacts</h1>
  <table class="table align-middle">
    <tbody>
      @foreach ($contacts as $contact)
        <tr>
          <td>{{ $contact->name }}</td>
          <td>{{ $contact->phone }}</td>
          <td class="text-end">
            {{-- view: a simple link; route() places the id in the path: /contacts/2 --}}
            <a class="btn btn-sm btn-outline-primary" href="{{ route('contacts.details', $contact->id) }}">View</a>
            {{-- delete: a form, whose submission will be confirmed --}}
            <form method="post" class="d-inline" action="{{ route('contacts.delete', $contact->id) }}"
                  data-confirmation="Delete the contact {{ $contact->name }}?">
              {{-- the anti-CSRF token, required for any request that is not a GET --}}
              @csrf
              {{-- the DELETE method, which an HTML form cannot send --}}
              @method('DELETE')
              <button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
            </form>
          </td>
        </tr>
      @endforeach
    </tbody>
  </table>
@endsection
  • line 13: a link, presented as a button by the classes of [Bootstrap] ([btn]...): the appearance has nothing to do with the HTTP method. [route('contacts.details', $contact->id)] places the ID in the path: [/contacts/2] (a single parameter can be provided without an array);
  • Lines 15–22: One form for each “Delete” button, with no fields other than the token and the method. The [d-inline] class displays it on the same line as the link. The [data-confirmation] attribute contains the confirmation message; the [data-...] attributes are unspecified and intended for the page’s [JavaScript];
  • line 18: the anti-CSRF token (example 18), required for any request that is not a GET;
  • line 20: the [@method] directive writes the hidden field [_method]: [<input type="hidden" name="_method" value="DELETE">].
@extends('layout')

@section('content')
  <h1 class="h3">Contact no. {{ $contact->id }}</h1>
  <form method="post" action="{{ route('contacts.save', $contact->id) }}">
    @csrf
    <div class="mb-2">
      <label class="form-label" for="name">Name</label>
      <input class="form-control" id="name" name="name" value="{{ $contact->name }}">
    </div>
    <div class="mb-3">
      <label class="form-label" for="phone">Phone</label>
      <input class="form-control" id="phone" name="phone" value="{{ $contact->phone }}">
    </div>
    {{-- two submit buttons for the same form --}}
    <button class="btn btn-primary" type="submit">Save</button>
    <button class="btn btn-outline-primary" type="submit"
            formaction="{{ route('contacts.duplicate', $contact->id) }}">Duplicate</button>
    <a class="btn btn-link" href="{{ route('contacts') }}">Back to the list</a>
  </form>
@endsection
  • Lines 16–18: Two submit buttons for the same form: “Save” sends the form to the address specified by its [action] attribute, and “Duplicate” sends it to the address specified by its [formaction] attribute (Example 20). This form does not use [@method]: the field [_method] would be submitted by both buttons, and the duplication would also become a request for PUT or DELETE;
  • Line 19: Return to the list: a link.

4.9.4. The confirmation window

The browser can display a confirmation box (function [JavaScript] [confirm]), but it is not very attractive, and its appearance depends on the browser. Here we use a modal window from [Bootstrap], displayed by a few lines of [JavaScript]. The window is a fragment included in the template and is therefore present—though hidden—on all pages:

{{-- a single window for the whole page, hidden (d-none) at first --}}
<div id="confirmation-modal" class="d-none">
  <div class="modal-backdrop fade show"></div>
  <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">Confirmation</h5>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="confirmation-modal-message"></p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-action="no">No, go back</button>
          <button type="button" class="btn btn-danger" data-action="yes">Yes, confirm</button>
        </div>
      </div>
    </div>
  </div>
</div>
  • line 2: the [d-none] class from [Bootstrap] hides the element;
  • lines 3–4: the gray background, followed by the window, are rendered by the classes of [Bootstrap];
  • line 11: the message's position;
  • lines 14–15: the two buttons, of type [button] (not [submit]: they do not submit any form), distinguished by their [data-action] attribute.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contacts</title>
  <link rel="stylesheet" href="{{ route('bootstrap', 'css/bootstrap.min.css') }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    @session('message')
      <div class="alert alert-success" role="status">{{ $value }}</div>
    @endsession
    <main>
      @yield('content')
    </main>
  </div>
  {{-- the confirmation window, present in all the pages --}}
  @include('fragments.confirmation')
  {{-- the script, at the end of the page: the elements it looks for already exist --}}
  <script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
  • line 19: the window, included on all pages;
  • line 21: the script, placed at the end of the page: when it runs, the elements it searches for already exist. It is stored in the [public/js] folder.

The script [public/js/app.js]:

// confirmation of the forms that carry the data-confirmation="..." attribute
// Without JavaScript, these forms are simply sent without confirmation.
(() => {
  const modal = document.getElementById('confirmation-modal');
  const message = document.getElementById('confirmation-modal-message');
  let pending = null; // the form to send if the user confirms

  // 1. we intercept the submission of the forms to confirm
  document.querySelectorAll('form[data-confirmation]').forEach((form) => {
    form.addEventListener('submit', (event) => {
      event.preventDefault(); // the submission is suspended
      pending = form;
      message.textContent = form.dataset.confirmation;
      modal.classList.remove('d-none'); // the window appears
    });
  });

  // 2. the two buttons of the window
  modal.addEventListener('click', (event) => {
    const action = event.target.dataset.action;
    if (action === 'yes') {
      pending.submit(); // submission, WITHOUT firing the 'submit' event again
    } else if (action === 'no') {
      modal.classList.add('d-none');
      pending = null;
    }
  });
})();
  • line 3: the code is enclosed in an anonymous function, which is executed immediately; its variables do not interfere with those of the other scripts on the page;
  • lines 4–6: the window, the message location, and the form awaiting confirmation;
  • lines 9–14: for each form that has a [data-confirmation] attribute, the [submit] event—triggered when the form is submitted—is intercepted. [preventDefault()] suspends submission; the form is put on hold, and its message ([dataset.confirmation], the value of the [data-confirmation] attribute) is displayed in the window, which appears;
  • lines 19–25: a single handler for clicks in the window: [event.target] is the element clicked; its attribute [data-action] specifies which button it is. “Yes” submits the pending form using the [submit()] method, which—unlike clicking a button—does not trigger the [submit] event (otherwise, the window would reappear); “No” hides the window.

Without [JavaScript], the form is simply submitted without confirmation: the page remains usable. This is progressive enhancement: [JavaScript] adds convenience, but the application does not depend on it.

4.9.5. Execution

The contact list:

Image

Clicking “Delete” displays the confirmation window:

Image

After confirmation, the (simulated) query DELETE, the redirection, and the pop-up message:

Image

A contact's profile, with its two send buttons:

Image

A request without an anti-CSRF token is rejected, and a nonexistent contact returns a 404 error:

curl -i -X POST -d "_method=DELETE" http://localhost:8000/contacts/2
curl -i http://localhost:8000/contacts/9
HTTP/1.1 419 unknown status
Content-Type: text/html; charset=utf-8
...
        <title>Page Expired</title>
...
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
...
        <title>Not Found</title>
...

The error pages are still the English ones from [Laravel]. The chapter on the request lifecycle will show how to replace them with custom pages.