Security Mechanisms in Laravel

A comprehensive overview of Laravel’s many safe features that can help you prevent painful errors.

Laravel is a mature PHP web framework with built-in support for almost everything modern applications need. But we won’t cover all these features here! Instead, we’ll look at a topic that isn’t talked about nearly as often: Laravel’s many security mechanisms that can help prevent painful bugs.

We will look at the following security mechanisms:

  • Prevention N+1

  • Protection against partially hydrogenated models

  • Attribute typos and renamed columns

  • Protection against mass appropriation

  • Model rigor

  • Force polymorphic matching

  • Long-term event monitoring

Each of these protections is configurable, and we recommend how and when to configure them.

Preventing the N+1 Problem

Many ORMs, including Eloquent, offer a “function” that allows model relationships to be lazily loaded. Lazy loading is convenient because you don’t have to think ahead about which relationships to fetch from the database, but it often leads to a performance problem known as the “N+1 problem.”

The N+1 problem is one of the most common problems people run into when using ORMs, and it’s often the reason why people avoid ORMs altogether. This is a bit of an overpowering since we can just turn off lazy loading!

Imagine a naive list of blog articles. We will show the blog title and author name.

$posts = Post::all();

foreach($posts as $post) {
    // `author` is lazy loaded.
    echo $post->title . ' - ' . $post->author->name;
}

This is an example of the N+1 problem! The first line selects all blog entries. Then, for each individual post, we run another query to get the post’s author.

SELECT * FROM posts;
SELECT * FROM users WHERE user_id = 1;
SELECT * FROM users WHERE user_id = 2;
SELECT * FROM users WHERE user_id = 3;
SELECT * FROM users WHERE user_id = 4;
SELECT * FROM users WHERE user_id = 5;

The “N+1” notation comes from the fact that for each of the n-many records returned by the first query, an additional query is performed. One initial request plus n-many more. N+1.

While each individual query is probably quite fast, collectively you can see a huge performance penalty. And since every single query is fast, that’s not what will show up in your slow query log!

With Laravel, you can use the preventLazyLoading method in the Model class to completely disable lazy loading. Problem solved! Really, it’s that simple.

You can add a method to your AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    Model::preventLazyLoading();
}

Now every attempt to lazily load a relation will throw a LazyLoadingViolationException. Instead of lazy loading, you will have to explicitly greedily load your relationships.

// Eager load the `author` relationship.
$posts = Post::with('author')->get();

foreach($posts as $post) {
    // `author` is already loaded.
    echo $post->title . ' - ' . $post->author->name;
}

Lazy loading of relationships does not affect the correctness of your application, only its performance. Ideally it would greedily load all the necessary relations, but if that doesn’t happen, it just skips and lazily loads the necessary relations.

Therefore, we recommend disabling lazy loading in every environment except production. Hopefully any lazy loading will be caught during local development or testing, but in the rare case that lazy loading does make it into production, your app will continue to work just as well, but a little slower.

To prevent lazy loading in non-production environments, you can add this to your AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Prevent lazy loading, but only when the app is not in production.
    Model::preventLazyLoading(!$this->app->isProduction());
}

If you want to log lazy loading violations in production, you can register your own lazy loading violation handler using the handleLazyLoadingViolationUsing static method in the Model class.

In the example below, we disable lazy loading in every environment, but in production we log a violation instead of throwing an exception. This will ensure that our application continues to work as intended, but we can go back and fix our lazy loading mistakes.

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Prevent lazy loading always.
    Model::preventLazyLoading();

    // But in production, log the violation instead of throwing an exception.
    if ($this->app->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            $class = get_class($model);

            info("Attempted to lazy load [{$relation}] on model [{$class}].");
        });
    }
}

Protection against partially hydrogenated models

In almost every book about SQL, one of the performance tips you’ll see is to “select only the columns you need.” This is good advice! You want the database to only retrieve and return data that you actually intend to use, because everything else is simply discarded.

Until recently, this was a difficult (and sometimes dangerous!) recommendation to follow in Laravel.

Eloquent models in Laravel are an active record implementation where each model instance is backed by a row in the database.

To get the user with ID 1, you can use the User::find() method in Eloquent, which runs the following SQL query:

SELECT * FROM users WHERE id = 1;

Your model will be fully hydrogenated, meaning that every column from the database will be present in the in-memory representation of the model:

$user = User::find(1);
// -> SELECT * FROM users where id = 1;

// Fully hydrated model, every column is present as an attribute.

// App\User {#5522
//   id: 1,
//   name: "Aaron",
//   email: "aaron@example.com",
//   is_admin: 0,
//   is_blocked: 0,
//   created_at: "1989-02-14 08:43:00",
//   updated_at: "2022-10-19 12:45:12",
// }

Selecting all columns is probably fine in this case! But if your users table is very wide format, contains LONGTEXT or BLOB columns, or you are selecting hundreds or thousands of rows, you should probably limit it to just the columns you intend to use. (Watch our schema videos to learn more about LONGTEXT and BLOB columns and why you should avoid fetching them unless you need them.)

You can control which columns are selected using the select method, which results in a partially hydrogenated model. Model memory contains a subset of attributes from a row in the database.

$user = User::select('id', 'name')->find(1);
// -> SELECT id, name FROM users where id = 1;

// Partially hydrated model, only some attributes are present.
// App\User {
//   id: 1,
//   name: "Aaron",
// }

This is where the dangers begin.

If you access an attribute that was not fetched from the database, Laravel simply returns null. Your code will think the attribute is null, but in fact it simply wasn’t fetched from the database. It may not be null at all!

In the following example, the model is partially hydrogenated with only id and name, and then the is_blocked attribute is accessed further. Since is_blocked was never fetched from the database, the value of the attribute will always be null, which means that every blocked user will be treated as if they were not blocked.

// Partially hydrate a model.
$user = User::select('id', 'name')->find(1);

// is_blocked was not selected! It will always be `null`.
if ($user->is_blocked) {
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

This exact example probably (probably) won’t happen, but when data retrieval and use are spread across multiple files, something like this could happen. There is no warning anywhere that the model is partially hydrogenated, and as requirements evolve you may find yourself accessing attributes that were never loaded.

With extreme care and 100% test coverage, you can prevent this from ever happening, but it is still a ready-to-use bullet aimed straight at your foot. For this reason, we recommend that you never change the SELECT statement that populates the Eloquent model.

Still!

The Laravel 9.35.0 release provides us with a new security feature to prevent this from happening.

In 9.35.0, you can call Model::preventAccessingMissingAttributes() to prevent access to attributes that have not been loaded from the database. Instead of returning null, an exception will be thrown and everything will stop. This is very good.

You can enable this new behavior by adding this to your AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    Model::preventAccessingMissingAttributes();
}

Please note that we have enabled this protection everywhere, regardless of the environment! You can only enable this protection in local development, but the most important place to enable it is in production.

Unlike protecting against the N+1 problem, preventing access to missing attributes is not a performance issue, it is an application correctness issue. Enabling this protection prevents your application from behaving unexpectedly or incorrectly.

Accessing attributes that have not been selected can lead to various disastrous consequences:

  • Data loss

  • Overwriting data

  • Treating free users as paid users

  • Treating paid users as free

  • Sending factually incorrect emails

  • Sending the same email dozens of times

  • And so on.

While throwing exceptions in production is inconvenient, it is much worse to have silent failures that can lead to data corruption. It’s better to face the exceptions and fix them.

Typos in attributes and renamed columns

This is a continuation of the previous section and another request to enable Model::preventAccessingMissingAttributes() in your production environments.

We just spent a lot of time looking at how preventAccessingMissingAttributes() protects you from partially hydrogenated models, but there are two more scenarios in which this method can protect you!

The first is typos.

Continuing with the is_blocked scenario from the previous example, if you accidentally misspell “blocked”, Laravel will simply return null instead of telling you that you were wrong.

// Fully hydrated model.
$user = User::find(1);

// Oops! Spelled "blocked" wrong. Everyone gets through!
if ($user->is_blokced) {
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

This particular example will likely be discovered during testing, but why take the risk?

The second scenario is renamed columns. If your column was originally named blocked and then you decide it makes more sense to name it is_blocked, you’ll need to make sure you go back through your code and update every mention of blocked. What if you miss one? It just becomes null.

// Fully hydrated model.
$user = User::find(1);

// Oops! Used the old name. Everyone gets through!
if ($user->blocked) {
    throw new \Illuminate\Auth\Access\AuthorizationException;
}

Enabling Model::preventAccessingMissingAttributes() will turn this silent failure into an overt one.

Protection against mass appropriation

Bulk assignment is a vulnerability that allows users to set attributes that they should not be allowed to use.

For example, if you have the is_admin property, you don’t want users to be able to arbitrarily promote themselves to admin! Laravel prevents this by default by requiring explicit permission to bulk assign attributes.

In this example, only the name and email attributes can be bulk assigned.

class User extends Model
{
    protected $fillable = [
        'name',
        'email',
    ];
}

It doesn’t matter how many attributes you pass when creating or saving a model. Only the name and email attributes will be saved:

// It doesn’t matter what the user passed in, only `name`
// and `email` are updated. `is_admin` is discarded.
User::find(1)->update([
    'name' => 'Aaron',
    'email' => 'aaron@example.com',
    'is_admin' => true
]);

Many Laravel developers choose to disable bulk assignment protection entirely and rely on query validation to exclude attributes. This is quite reasonable! Just make sure you never pass $request->all() to your model’s save methods.

You can add this to your AppServiceProvider to completely disable bulk assignment protection.

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // No mass assignment protection at all.
    Model::unguard();
}

Remember: you take a risk when you remove restrictions from your models! Make sure you never pass all the request data indiscriminately.

// Only update `name` and `email`.
User::find(1)->update($request->only(['name', 'email']));

If you decide to leave bulk assignment protection enabled, there is another method that you will find useful: the Model::preventSilentlyDiscardingAttributes() method.

In the case where your fillable attributes are only name and email, and you are trying to update the birthday attribute, then the birthday attribute will be silently discarded without warning.

// We’re trying to update `birthday`, but it won’t persist!
User::find(1)->update([
    'name' => 'Aaron',
    'email' => 'aaron@example.com',
    'birthday' => '1989-02-14'
]);

The birthday attribute is discarded because it is not populated. This is anti-mass appropriation in action, and that’s what we want! It’s just a little confusing because it’s silent instead of explicit.

Laravel now provides a way to make this silent error explicit:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Warn us when we try to set an unfillable property.
    Model::preventSilentlyDiscardingAttributes();
}

Instead of silently discarding the attributes, a MassAssignmentException will be thrown and you’ll immediately know what’s going on.

This protection is very similar to the preventAccessingMissingAttributes protection. It primarily ensures application correctness over application performance. If you expect data to be saved but it isn’t, that’s an exception and should never be silently ignored, regardless of the runtime.

For this reason, we recommend enabling this protection in all runtime environments!

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Warn us when we try to set an unfillable property,
    // in every environment!
    Model::preventSilentlyDiscardingAttributes();
}

Model rigor

Laravel 9.35.0 provides a Model::shouldBeStrict() helper method that controls three Eloquent “strictness” parameters:

  • Model::preventLazyLoading()

  • Model::preventSilentlyDiscardingAttributes()

  • Model::preventsAccessingMissingAttributes()

The idea here is that you can use the shouldBeStrict() call in your AppServiceProvider and enable or disable all three options in one method. Let’s take a quick look at our recommendations for each option:

  • preventLazyLoading: Primarily for application performance. Disabled for production, enabled locally. (Unless you file violations in production.)

  • preventSilentlyDiscardingAttributes: Primarily for application correctness. Included everywhere.

  • preventsAccessingMissingAttributes: Primarily for application correctness. Included everywhere.

Based on this, if you plan to log lazy loading violations in production, you can configure your AppServiceProvider like this:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // Everything strict, all the time.
    Model::shouldBeStrict();

    // In production, merely log lazy loading violations.
    if ($this->app->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            $class = get_class($model);

            info("Attempted to lazy load [{$relation}] on model [{$class}].");
        });
    }
}

If you don’t plan to log lazy loading violations (which is a perfectly reasonable decision!), then you would configure your settings like this:

use Illuminate\Database\Eloquent\Model;

public function boot()
{
    // As these are concerned with application correctness,
    // leave them enabled all the time.
    Model::preventAccessingMissingAttributes();
    Model::preventSilentlyDiscardingAttributes();

    // Since this is a performance concern only, don’t halt
    // production for violations.
    Model::preventLazyLoading(!$this->app->isProduction());
}

Forcing polymorphic matching

A polymorphic relation is a special relation type that allows many parent model types to share the same child model type.

For example, a blog and a user could both have images, and instead of creating a separate image model for each, you could create a polymorphic relationship. This allows you to have a single Image model that serves both the Post and User models. In this example, Image is a polymorphic relation.

In the images table, you will see two columns that Laravel uses to find the parent model: an imageable_type column and an imageable_id column.

The imageable_type column stores the model type in the form of a fully qualified class name (FQCN), and imageable_id is the model’s primary key.

mysql> select * from images;
+----+-------------+-----------------+------------------------------+
| id | imageable_id | imageable_type | url                          |
+----+-------------+-----------------+------------------------------+
|  1 |           1 | App\Post        | https://example.com/1001.jpg |
|  2 |           2 | App\Post        | https://example.com/1002.jpg |
|  3 |           3 | App\Post        | https://example.com/1003.jpg |
|  4 |       22001 | App\User        | https://example.com/1004.jpg |
|  5 |       22000 | App\User        | https://example.com/1005.jpg |
|  6 |       22002 | App\User        | https://example.com/1006.jpg |
|  7 |           4 | App\Post        | https://example.com/1007.jpg |
|  8 |           5 | App\Post        | https://example.com/1008.jpg |
|  9 |       22003 | App\User        | https://example.com/1009.jpg |
| 10 |       22004 | App\User        | https://example.com/1010.jpg |
+----+-------------+-----------------+------------------------------+

This is standard Laravel behavior, but storing fully qualified class names (FQCNs) in your database is not good practice. Binding the data in your database to a specific class name is very fragile and can lead to unexpected breaks if you ever refactor your classes.

To prevent this, Laravel gives us a way to control which values ​​end up in the database using the Relation::morphMap method. Using this method, you can give each class that is used in a polymorphic relationship a unique key that never changes, even if the class name changes:

use Illuminate\Database\Eloquent\Relations;

public function boot()
{
    Relation::morphMap([
        'user' => \App\User::class,
        'post' => \App\Post::class,
    ]);
}

Now we have broken the connection between our class name and the data stored in the database. Instead of seeing \App\User in the database, we will see user. This is already a good result!

However, we are still subject to one potential problem: this mapping is not necessary. We could create a new Comment model and forget to add it to the morphMap, and Laravel will use FQCN by default, leaving us in a bit of a mess.

mysql> select * from images;
+----+-------------+-----------------+------------------------------+
| id | imageable_id | imageable_type | url                          |
+----+-------------+-----------------+------------------------------+
|  1 |           1 | post            | https://example.com/1001.jpg |
|  2 |           2 | post            | https://example.com/1002.jpg |
| .. |         ... | ....            |  . . . . . . . . . . . . . . |
| 10 |       22004 | user            | https://example.com/1010.jpg |
| 11 |          10 | App\Comment     | https://example.com/1011.jpg |
| 12 |          11 | App\Comment     | https://example.com/1012.jpg |
| 13 |          12 | App\Comment     | https://example.com/1013.jpg |
+----+-------------+-----------------+------------------------------+

Some of our imageable_type values ​​are correctly unbound, but because we forgot to map the App\Comment model to the key, the fully qualified class name still ends up in the database!

Laravel supports us (again) by providing a method to ensure that each converted model is mapped. You can change your morphMap call to enforceMorphMap, and the default behavior of using a fully qualified class name is disabled.

use Illuminate\Database\Eloquent\Relations;

public function boot()
{
    // Enforce a morph map instead of making it optional.
    Relation::enforceMorphMap([
        'user' => \App\User::class,
        'post' => \App\Post::class,
    ]);
}

Now, if you try to use a new transformation that you haven’t mapped, you’ll be thrown a ClassMorphViolationException, which you can fix before the bad data hits the database.

The trickiest glitches are the ones that happen without warning; It’s always better to have obvious failures!

Preventing accidental HTTP requests

When testing your application, it is common to forge outgoing requests to third-party services so that you can control different testing scenarios and not be spammed by your providers.

In Laravel we have had a way to do this for a long time by calling Http::fake() which forges all outgoing HTTP requests. However, more often than not you want to fake a specific request and provide a response:

use Illuminate\Support\Facades\Http;

// Fake GitHub requests only.
Http::fake([
    'github.com/*' => Http::response(['user_id' => '1234'], 200)
]);

In this scenario, outgoing HTTP requests to any other domain will not be forged and will be sent as normal HTTP requests. You may not notice this until you realize that certain tests are slow or until you start running into speed limits.

Laravel 9.12.0 introduced the preventStrayRequests method to protect you from failed requests.

use Illuminate\Support\Facades\Http;

// Don’t let any requests go out.
Http::preventStrayRequests();

// Fake GitHub requests only.
Http::fake([
    'github.com/*' => Http::response(['user_id' => '1234'], 200)
]);

// Not faked, so an exception is thrown.
Http::get('https://planetscale.com');

This is another good defense that is always worth turning on. If your tests must access external services, you must explicitly allow this. If you have a test base class, I recommend putting it in the setUp method of that base class:

protected function setUp(): void
{
    parent::setUp();

    Http::preventStrayRequests();
}

In any tests where you need to allow non-forged requests to be sent, you can re-enable this by calling Http::allowStrayRequests() on that particular test.

Long-term event monitoring

These last few methods are not aimed at preventing individual misbehaviors, but rather at monitoring the entire application. These methods can be useful if you don’t have an application performance monitoring tool.

Long database queries

Laravel 9.18.0 introduced the DB::whenQueryingForLongerThan() method, which allows you to fire a callback when the cumulative execution time of all your queries exceeds a certain threshold.

use Illuminate\Support\Facades\DB;

public function boot()
{
    // Log a warning if we spend more than a total of 2000ms querying.
    DB::whenQueryingForLongerThan(2000, function (Connection $connection) {
        Log::warning("Database queries exceeded 2 seconds on {$connection->getName()}");
    });
}

If you want to fire a callback when one request takes a long time, you can do this using the DB::listen callback.

use Illuminate\Support\Facades\DB;

public function boot()
{
    // Log a warning if we spend more than 1000ms on a single query.
    DB::listen(function ($query) {
        if ($query->time > 1000) {
            Log::warning("An individual database query exceeded 1 second.", [
                'sql' => $query->sql
            ]);
        }
    });
}

Again, these methods are useful if you don’t have an application performance monitoring (APM) tool or a query monitoring tool like PlanetScale’s Query Insights.

Request and Command Lifecycle

Similar to monitoring long requests, you can monitor when the lifecycle of your request or command takes longer than a certain threshold. Both of these methods are available since Laravel 9.31.0.

use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;

public function boot()
{
    if ($this->app->runningInConsole()) {
        // Log slow commands.
        $this->app[ConsoleKernel::class]->whenCommandLifecycleIsLongerThan(
            5000,
            function ($startedAt, $input, $status) {
                Log::warning("A command took longer than 5 seconds.");
            }
        );
    } else {
        // Log slow requests.
        $this->app[HttpKernel::class]->whenRequestLifecycleIsLongerThan(
            5000,
            function ($startedAt, $request, $response) {
                Log::warning("A request took longer than 5 seconds.");
            }
        );
    }
}

Make the Implicit Explicit

Many of these Laravel safety features turn implicit behaviors into explicit exceptions. At the beginning of a project, it’s easy to remember all the implicit behaviors, but over time, it’s easy to forget one or two of them and end up in a situation where your application doesn’t behave as expected.

You already have enough to worry about. Take some of the pressure off yourself by turning on these safety mechanisms!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *