Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Introduce Laravel Precognition #44339

Merged
merged 4 commits into from
Sep 29, 2022
Merged

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented Sep 28, 2022

Precognition

Precognition (from the Latin prae- 'before', and cognitio 'acquiring knowledge'), is the purported psychic phenomenon of seeing, or otherwise becoming directly aware of, events in the future.

🔮 Source: Wikipedia

Screen Shot 2022-08-11 at 2 30 34 pm

Image source: The "Precogs" from the film Minority Report


tl;dr;

Precognition is a new framework feature that will allow developers to create new and improved experiences for their users.

Precognition introduces a request / response header, middleware, and for some cases a global helper that Laravel reacts to in a unique manner.

When a Precognition request comes into Laravel, everything right up until the controller is executed. This includes all middleware, route model binding resolution, form request validation etc.

This unlocks new flows and possibilities for applications, such as:

  • Progressive validation of front-end forms while co-locating all validation logic in the back-end, so that front-end forms are validated by the server instead of a front-end library (removing the need to duplicate logic across stacks).
  • As an alternative to the above, it also allows for requests that target validation only possible via the back-end (i.e. validation that relies on the state of the database) while also implementing state-less validation on the front-end.
  • A mix of both of the above.
  • Determining if the state of the underlying entity has been updated since it was retrieved and notifying the user that the record has been updated (see: Laravel Nova's traffic cop feature).
  • Determining if the current resource has been locked by another user editing the same form (see WordPress' "This post is currently locked, would you like to take over" functionality).

And no doubt many more general and application context specific usecases.

And all of this is possible without creating any new routes, but by applying the new Precognition middleware to any route you would like this feature available on.

What type of applications can benefit from this feature?

  • Single Page Applications
  • API only applicationss
  • Inertia Apps
  • Blade apps with JavaScript sprinkles

The only type of app that cannot tap into the benefits this feature offers is true Blade only applications that does not, and does not intend to, utilise any JavaScript, and Livewire applications, as I believe some of this functionality may already be available.

Example: Improving validation UX

I think the best way to understand this feature is to look at some examples. So let's dive right into using Precognition to give a better validation experience.

Note: Precognition itself is not a validation specific feature, but validation does serve as a good example of what this feature unlocks for applications. We have also baked in some nice validation features to Precognition so you can hit the ground running.

We'll work with the following sign up form as an example:

Screen Shot 2022-08-17 at 2 02 39 pm

For this form, we have the following route and form request object.

// Route..

Route::post('accounts', [AccountController::class, 'store']);

// Form Request...

class StoreAccountRequest extends FormRequest
{
    protected function rules()
    {
        return [
            'username' => [
                'required',
                'string',
                'min:3',
                'max:16',
                Rule::unique('users'),
            ],
            'password' => [
                'required',
                'string',
                Password::uncompromised(),
            ],
            'name' => [
                'required',
                'string',
                'min:1',
                'max:300',
            ],
            'phone' => [
                'required',
                'string',
                new FormattedPhoneNumber(),
            ],
        ];
    }
}

...and things worked. But then when we want to improve the UX of this form, we now want to push for some more real-time validation, so that the user doesn't have to submit the form only to discover that their username has already been taken.

We could duplicate a lot of this state-less validation in the front-end - min:3 and max:16 are trivial to duplicate (☝️ but doing this does mean we need to keep things in sync).

However, there is no way for our front-end to determine if the username is actually unique in our database.

With Precognition, we can anticipate the outcome of the form submission and determine if the username is in fact unique before the form is submitted. Here is how we would do that:

First thing we need to do is opt-in to Precognition for this route by adding the Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests middleware:

use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;

Route::post('accounts', [AccountController::class, 'store'])
    ->middleware([HandlePrecognitiveRequests::classs]);

That is all that is required for the backend. Moving into the front-end we can add an "onChange" listener on the username field and send the following request - taking note of the "Precognition" headers:

REQUEST

POST: http://example.com/accounts
Accept: application/json
Precogntion: true
Precogntion-Validate-Only: username

{"username":"timacdonald"}

Note We have front-end Precognition helper libraries that make this a straightforward process to set up.

The Precogntion: true header tells Laravel that the client is attempting a Precognition request. The Precogntion-Validate-Only: username header tells Laravel that the client only wants to run the validation rules for the username input.

Note When using this validation filtering, the execution will always stop after validation has finished - even when the data is valid. This ensures that the program does not continue with an incomplete valid payload.

The code for the application then runs right up until the Controller would be invoked, which means the form request is resolved and it's validation process runs. If the username is not unique (or doesn't meet the other validation requirements), a validation response will be returned.

RESPONSE

HTTP/1.1 422 UNPROCESSABLE ENTITY
Content-Type: application/json
Precognition: true

{
    "message": "The given data was invalid.",
    "errors": {
        "username": [
            "The username is not unique"
        ]
    }
}

Which the front-end can then utilise to notify the user long before the user has moved on and submitted the form (again, the front-end libraries make handling the validation error a breeze).

You will also note that although the other inputs are required, they did not return a validation response. That is because we requested that only the username input be validated.

Alternatively, if the validation was successful a 201 No Content response with a Precognition true header is returned.

RESPONSE

HTTP/1.1 204 NO CONTENT
Precognition: true

This allows the frontend to provide early feedback to the user about their username being valid.

Screen Shot 2022-08-17 at 2 04 24 pm

So we have just allowed somewhat real-time validation on our form and improved the users experience. The user now gets feedback on their username before they have completed the form.

There are of course more considerations an application needs to make around rate limiting, debouncing requests, cancelling in-flight requests, and what is allowed to run during a Precognition request - but don't worry, we've sweated the small stuff and our front-end libraries handle all of this for you.

While we are discussing validation, there are some additional out-of-the-box validation features that are worth mentioning before we move on to other possibilities with Precognition.

Server-side Rule Filtering

We have seen how the client can ask for specific inputs to be validated, but what we haven't seen is how the server can specify which rules should be run during Precognition.

By default, all specified validation rules are run during Precognition, however it is possible to exclude validation rules from a Precognition request on the server side.

Let's say that we only wanted the back-end to validate the things we can not easily replicate on the front-end (the state based rules. We can tell the form request to exclude validation rules from Precognitive requests like so:

class StoreAccountRequest extends FormRequest
{
    protected function rules()
    {
        return [
            'username' => [
                ...$this->isPrecognitive()
                    ? [Rule::unique('users')]
                    : [
                        'required',
                        'string',
                        'min:3',
                        'max:16',
                        Rule::unique('users'),
                    ],
            ],
            // ...
        ];
    }
}

In the above form request, only the Rule::unique('users') rule would ever be against the username during a Precognition request. The client may still ask for only the username rule to be run, but when it does, all the other username rules, such as min, max, etc would not be executed.

Of course, depending on your needs and usecase, you may actually find it is the stateless rules that you want to allow on Precognitive requests and leave the more performance heavy validation rules for the final submission. This would mean that the CPU footprint would be much smaller for your Precognition requests, as everything is handled in-memory while giving you some nice real-time validation without duplicating rules across stacks. You would just switch the above logic to achieve this.

The Framework can not handle application specific scenarios, so as we have seen the request provides a $request->isPrecognitive() function for use in your own code to determine if you would like to exclude certain functionality from a precognitive request.

This is useful for middleware if you want to exclude some side-effect functionality from a precognitive request. Same goes for after validation rules.

class Middleware
{
    public function handle($request, $next)
    {
        if (! $request->isPrecognitive()) {
            $this->service->persist();
        }

        return $next($request);
    }
}


class StoreAccountRequest extends FormRequest
{
    protected function withValidator($validator)
    {
        $validator->after(function ($validator) {

            if (! $this->isPrecognitive()) {
                // ...
            }

        });
    }

    /* ... */
}

So at this point I'm hoping I've got you onboard with the idea and how you can work with validation ✅ so let's move onto some other possibilities.

Example: Detecting updated records

One problem often faced by applications where multiple users can access and edit the same information is what to do when a record is updated by another user while the original user is still editing a record.

This can lead to missing updates and all sorts of problems for your application. Lost data. Lost time. What. A. Pain.

Wouldn't it be nice if the application could instead see into the future and warn the user closer to when a conflict is created, rather than waiting for the form to be submitted?

Assuming the application has implemented this a conflict check in a middleware, something like:

class ConflictCheck
{
    public function handle($request, $next, $resource)
    {
        if ($request->route($resource)->updated_at->isAfter($request->last_updated_at)) {
            return response('The resource has been updated.', 409);
        }

        return $next($request);
    }
}

The front-end may send Precognitive requests periodically to ensure that the resource has not been updated while it is being edited. Here is an extremely naive (read: don't copy and paste this) example of how to achieve this in a low level manner (without any overlaying sugar):

The front-end libraries make polling with Precognition a first-party concern.

Example: Detecting locked records

In contrast to the above where we detect conflicts after another user has edited a record, another nice usecase for Precognition is stopping two users from editing the same record at the same time - and also for letting users "take over" the resource if another user currently has it locked. This is a slightly different approach to the above example, but hopes to achieve a similar thing.

In a similar manner to the above example, the front-end could periodically check in with the server by performing a Precognition request and the server could respond with a 423 Locked response if another use has locked the resource.

This is interesting as it would allow users to ping pong the lock access. i.e. I could take over editing from another user and they would be notified that I locked the resource.

Screen Shot 2022-08-17 at 4 16 04 pm

So after all that: Why Precognition?

Now that we have taken a look at some examples, lets talk about the feature generally. Let's face it, everything I've shown you is already possible with Laravel. We can already do these things...but in order to do them we need to create dedicated endpoints that don't trigger side-effects / duplicate code paths or we need to do a bunch of manual work with query parameters or something to handle things ourselves.

Precognition establishes patterns and makes those patterns first class citizens of Laravel to help enable developers to create amazing experiences for their users - just by adding a middleware to their route.

Advanced feature: Running code in the controller

Everything we have seen so far has shown you how things "just work" outside of the controller, however it may be the case that you like to keep things within your controller. To do this, you will need to extend the Precognition middleware and re-bind the standard controller dispatcher classes.

When you are using this option, it is then on the developer to ensure that they call the precognitive global helper when they want precognitive requests to stop (note that when filtering validation, the execution will still stop after calling $this->validate().

class PostController
{
    public function update(Request $request, Post $post)
    {
        $payload = precognitive(function ($bail) {
            $this->authorize('update', $post);

            if ($this->updateConflicts($request, $post)) {
                $bail(response('Post has been updated.', 409));
            }

            return $this->validate($request, [
                //
            ]);
        });

        $post->update($payload);

        return PostResource::make($post);
    }
}

Whatever is returned from the closure is returned to the controller. If you want to return multiple things, you can use destructuring:

[$post, $payload] = precognitive(function () use ($request) {
    $post = Post::findOrFail($request->post_id);

    /* ... */

    return [$post, $payload];
});

The code in the clousure is executed for both Precognition and non-Precognition requests - however a Precognition request will always end once the closure has been invoked and return the "empty response", unless you have invoked the $bail function.

Notes

  • Applications should consider if they need to make adjustments to rate limiting when enabling Precognition.
  • Application caches may need to take the "Vary" header returned in Precognitive requests into account when deciding to cache responses.
  • Precognition does not prevent side-effects for occurring in your own code. You may need to make tweaks based on your application for Precognitive requests.
  • If you want to send specific responses for Precognition, you can do the following...
return ! $request->isPrecognitive()
    ? redirect()->back()
    : response()->json();


// or the same using the `$bail` callable

$bail(redirect()->back(), response()->json());

This is only needed for Blade apps with JS sprinkles, or Inertia apps.

Huge thanks to everyone on the Laravel team for all their help and feedback on this feature ❤️

@timacdonald
Copy link
Member Author

Documentation: laravel/docs#8261

@LasseRafn
Copy link
Contributor

Super exciting feature!

@heychazza
Copy link

heychazza commented Sep 29, 2022

This is epic, thanks for pushing leaps forward with implementations like this! Will be so useful for my business.

So normal rate limits for requests will still apply here?

@aneesdev
Copy link

@timacdonald nice one as always. I think this can be at 10.x instead of 9.x

@alexanderkroneis
Copy link

I just wanted to let you know that the description of this PR is out of this world. Good job, I am really excited for this feature. 💯

@markvaneijk
Copy link
Contributor

Top notch!

@taylorotwell taylorotwell merged commit 103ae1e into laravel:9.x Sep 29, 2022
@driesvints
Copy link
Member

Hey all, I sent in a PR to the skeleton to turn this on by default: laravel/laravel#5997. Would love to have some feedback there 👍

@mreduar
Copy link

mreduar commented Sep 30, 2022

Thank you @timacdonald !

freekmurze pushed a commit to spatie/laravel-missing-page-redirector that referenced this pull request Oct 13, 2022
closes #77

As the container parameter is nullable in `Illuminate\Routing\Router`'s constructor, an empty container is assigned in its constructor when no container is provided.

Since laravel/framework#44339 introduced `Illuminate/Routing/Contracts/CallableDispatcher`, a router needs this interface bound to dispatch closure bound routes such as the ones registered here:

https://github.com/spatie/laravel-missing-page-redirector/blob/ba2bc5f2e9cf3be883c311c125c756903eae412d/src/MissingPageRouter.php#L42-L53

This PR

- Passes Laravel Container to the `Router` constructor in this package's Service Provider, so it has a container which knows how to build a `CallableDispatcher` instance.
@JoshSalway
Copy link

JoshSalway commented Nov 9, 2022

Is there any documentation for this yet? Can it be used with Livewire and Alpine for real-time form validation? 🤔

@mreduar
Copy link

mreduar commented Nov 9, 2022

Is there any documentation for this yet? Can it be used with Livewire and Alpine for real-time form validation? 🤔

The documentation is here, it is not yet usable

@krekas
Copy link

krekas commented Nov 14, 2022

Is there any documentation for this yet? Can it be used with Livewire and Alpine for real-time form validation? thinking

You don't need this to validate in real-time with Livewire

@christhofer
Copy link

Just read this feature on Laravel News May 17.

Found the NPM package for Vue (https://www.npmjs.com/package/laravel-precognition-vue), But the GitHub link is gone.

The documentation link (https://laravel.com/docs/precognition) is error 404.
image

@rodrigopedra
Copy link
Contributor

@christhofer docs for precognition are not merged yet.

You can review the pull request for docs, but that can't be considered final: laravel/docs#8261

I guess Precognition is still an undocumented feature.

@Ajmal418
Copy link

Ajmal418 commented Jan 4, 2024

can i used the precognition in blade file can any one explain with the example if all of you can help 'it's great request

@ceejayoz
Copy link
Contributor

ceejayoz commented Jan 4, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet