Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 5 – Databases and Eloquent

Eloquent models, mass assignment, soft deletes

Set up Eloquent models for Pathshala's fees, turn on strict mode, upsert a thousand instalments in one statement, and soft-delete a row you can restore.

  • Beginner
  • 10 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Set up an Eloquent model with Fillable and switch on strict mode outside production
  • Create, read, update and delete rows, with firstOrCreate and updateOrCreate
  • Write one upsert that saves a thousand rows in one statement, and read the query count to prove it
  • Use the SoftDeletes trait to soft-delete, restore and permanently delete rows
  • Choose between an auto-increment, UUID or ULID key, and still authorize every access

Before you start

What you’ll learn · Before you start

What you will learn

  • Set up an Eloquent model with Fillable and switch on strict mode outside production
  • Create, read, update and delete rows, with firstOrCreate and updateOrCreate
  • Write one upsert that saves a thousand rows in one statement, and read the query count to prove it
  • Use the SoftDeletes trait to soft-delete, restore and permanently delete rows
  • Choose between an auto-increment, UUID or ULID key, and still authorize every access

Before you start

On this page

Pathshala’s accountant imports fee instalments from a spreadsheet of 100 students at a time: 1,000 rows a term. If each row runs its own insert, that is 1,000 round trips through the database driver; if one of those rows has a key nobody declared on the model, the row is written anyway and the extra field is dropped in silence. This lesson settles both problems: a model with a short Fillable list, a boot method that turns on strict mode outside production, and an upsert call whose query count the probe prints as 1.

Along the way you will see what each strict-mode guard refuses, read a soft-deleted row with withTrashed, restore it, and give one Pathshala table a ULID key while still writing the authorization that an unguessable id does not replace.

A model is a row, and strict mode makes the slips loud

An Eloquent model: A PHP class that stands for one database table, each of its objects for one row: the Notice model queries the notices table, and saving a Notice object writes its row. More in “Your first CRUD feature with Eloquent” is a PHP class that stands for one database row. Pathshala’s fee sheet needs two of them, kept in app/Models/: Student for one row of the students table, and FeeInstalment for one row of the fee_instalments table. This lesson’s migration: A schema change written as a PHP class with an up method that applies it and a down method that undoes it, kept in database/migrations and run in file-name order. More in “Your first CRUD feature with Eloquent” gives the fee_instalments table a unique index on (student_id, due_on), so no two instalments belong to the same student on the same date; the model is what the rest of the code uses to read and write that table.

The fee_instalments migration the two models read PHP · pathshala-model/database/migrations/2026_01_05_091000_create_fee_instalments_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('fee_instalments', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('student_id')->index();
            $table->date('due_on');
            $table->unsignedInteger('amount_paise');
            $table->string('status', 10)->default('due');
            $table->softDeletes();
            $table->timestamps();
            $table->unique(['student_id', 'due_on']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('fee_instalments');
    }
};
app/Models/Student.php PHP · pathshala-model/app/Models/Student.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[Fillable(['name', 'phone'])]
class Student extends Model
{
    public function instalments(): HasMany
    {
        return $this->hasMany(FeeInstalment::class);
    }
}
app/Models/FeeInstalment.php PHP · pathshala-model/app/Models/FeeInstalment.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;

#[Fillable(['student_id', 'due_on', 'amount_paise', 'status'])]
class FeeInstalment extends Model
{
    use SoftDeletes;

    protected $casts = [
        'due_on' => 'date',
    ];

    public function student(): BelongsTo
    {
        return $this->belongsTo(Student::class);
    }
}

The #[Fillable([...])] attribute names the four columns a request array may write to a fee instalment, and nothing else. That is the mass assignment: Creating or updating a model from a whole array of fields in one call, which is why a model declares the columns such an array is allowed to write. More in “Your first CRUD feature with Eloquent” boundary from the first-feature module: whatever is not on the list is dropped before Eloquent builds the insert.

Dropped silently, by default. In development, three default behaviours of Eloquent hide bugs you want to see at once: lazy loading a relationship inside a loop runs an extra query per row, which only shows up when the page is slow; a request array with a stray key writes the row without reporting the key; and selecting a subset of columns then reading one of the others returns null, which looks like an empty column. Laravel offers one method, Model::shouldBeStrict(), that turns all three into exceptions.

app/Providers/AppServiceProvider.php: switch strict mode on outside production PHP · strict-mode-boot/AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        Model::shouldBeStrict(! $this->app->isProduction());
    }
}

strict mode: A development-only Eloquent setting that turns three default behaviours into exceptions: lazy loading of a relationship, writing a column the model has not declared, and reading a column the query did not select. only runs when isProduction() is false, so a production site keeps its old habit. The probe below seeds two students and four instalments without the guards, turns them on, and provokes each in turn, catching the exception each throws:

Strict mode: the three exceptions, one after another Python · strict_mode_session.py
from session import run

# Model::shouldBeStrict() turns on three guards at once: no lazy loading, no silent discard of unfillable keys,
# and no reading of a column the query did not select. The probe below seeds two students and four instalments
# without those guards, then switches them on and provokes each in turn, catching the exception each throws.
run(
    """
php artisan migrate --no-interaction
php strict_mode_probe.php
""",
    app=True,
    overlay=("pathshala-model", "strict-mode"),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_05_090000_create_students_table ............................... DONE
  2026_01_05_091000_create_fee_instalments_table ........................ DONE
$ php strict_mode_probe.php
Guard 1: lazy loading inside a loop
Illuminate\Database\LazyLoadingViolationException: Attempted to lazy load [instalments] on model [App\Models\Student] but lazy loading is disabled.

Guard 2: an array key the model does not allow
Illuminate\Database\Eloquent\MassAssignmentException: Add fillable property [written_off] to allow mass assignment on [App\Models\FeeInstalment].

Guard 3: a column the query did not select
Illuminate\Database\Eloquent\MissingAttributeException: The attribute [name] either does not exist or was not retrieved for model [App\Models\Student].

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strict_mode_session.py

Three exceptions arrived in order and named the model and the attribute each time: LazyLoadingViolationException for the instalments relation accessed inside the loop, MassAssignmentException for the written_off key the model does not declare, and MissingAttributeException for the name column the select('id') query left out.

Interactive · stage by stage The three slips strict mode names Step through what each of the three guards catches, as the probe triggered them in turn.
The three slips strict mode names, step 4 of 4. Showing Model::shouldBeStrict(), Lazy load in a loop, An unknown array key, An unselected column. In focus: An unselected column.Model::shouldBeStrict()in a non-production bootLazy load in a loopLazyLoadingViolationAn unknown array keyMassAssignmentExceptionAn unselected columnMissingAttributeException

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. One line of boot. shouldBeStrict() turns on three guards at once. In production the line is skipped, so the running site keeps its old habit of fetching one row at a time, dropping unknown keys and reading NULL for a column the query did not select.
  2. A relation inside a loop. A loop over students that reads $student->instalments runs one extra query per student. With the first guard on, Eloquent throws LazyLoadingViolationException instead, and names the relation it refused to load.
  3. An extra array key. FeeInstalment::create() passed an extra key the Fillable list does not name. With the second guard on, the call throws MassAssignmentException and names the column, instead of writing the row and ignoring the extra silently.
  4. A column that was not selected. Student::select('id')->first() left out the name column, then the code read $student->name. With the third guard on, Eloquent throws MissingAttributeException with the model and the column, so the developer sees that the select left it out.

A column default is not an answer

Student::select('id')->first()->name returns null while the guard is off, and plenty of code takes that for the value Eloquent held for the row. The third guard refuses it with an exception that names the model and the attribute, so the fix (adding the column to the select() or asking for the row with find()) is a one-line change you make at the point of the slip.

Checkpoint

Check: Strict mode guards

  1. Question 1 of 2 Which strict-mode guard refuses the call FeeInstalment::create(['student_id' => 1, 'due_on' => $dueOn, 'amount_paise' => 500000, 'status' => 'due', 'written_off' => true])?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: preventSilentlyDiscardingAttributes, with MassAssignmentException

    Strict mode calls three preventers. The one that fires on an extra array key is preventSilentlyDiscardingAttributes, and the exception names the model and the column so the fix is a one-line change to Fillable.

  2. Question 2 of 2 Why does AppServiceProvider pass ! $this->app->isProduction() to Model::shouldBeStrict()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: So the guards are on while you develop and off on the live site

    shouldBeStrict turns on three preventers that catch slips a developer should see at the first slow test. Live visitors should not see those exceptions as 500 pages, which is why the production branch keeps the old behaviour.

Create, read, update and delete, with firstOrCreate and updateOrCreate

Every row’s life passes through four actions, and Eloquent gives each a short name. create() builds a row from a Fillable array and saves it; find() reads one row by its primary key, where()->first() by any column; a model instance’s property setter plus save() or an array passed to update() writes the changed row back; delete() removes it, or marks it as deleted when the model is soft-deletable.

Two more combined moves cover the common cases. firstOrCreate([keys], [defaults]) reads the row that matches the first array and inserts it with the two arrays merged if there is no match. updateOrCreate([keys], [values]) is the write version: it finds the matching row and sets values on it, or inserts the merged arrays. The wasRecentlyCreated property says which side it took, so the controller after it knows whether to log a new row or a changed one.

firstOrCreate reads or inserts; updateOrCreate writes or inserts PHP · snippets/find_or_create.php
<?php

use App\Models\FeeInstalment;

// read or create: idempotent on the keys array alone.
$apr = FeeInstalment::firstOrCreate(
    ['student_id' => 7, 'due_on' => '2026-04-05'],
    ['amount_paise' => 500000, 'status' => 'due'],
);

// write or create: the values on the right are the write.
$may = FeeInstalment::updateOrCreate(
    ['student_id' => 7, 'due_on' => '2026-05-05'],
    ['amount_paise' => 550000, 'status' => 'due'],
);

if ($may->wasRecentlyCreated) {
    // the row was inserted this call; log it to the audit trail.
}

The keys of the first array must land on columns the database agrees are unique together, which is why the migration gave (student_id, due_on) its unique index. Two accountants running firstOrCreate on the same month produce one row, not two: the database refuses the second insert, and firstOrCreate then reads the row the first one wrote.

Checkpoint

Check: Create, read, update, delete

  1. Question 1 of 2 firstOrCreate(['student_id' => 7, 'due_on' => $aprilDue], [...]) is run twice in a row. What does the second call return, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The same row the first call wrote; nothing is inserted the second time

    firstOrCreate's first array is the match. If the unique index is on the same columns, two calls on the same month produce one row. The defaults array is only used when the row has to be inserted.

  2. Question 2 of 2 What does $row->wasRecentlyCreated tell you after an updateOrCreate call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Whether the row was inserted this call or found and updated

    updateOrCreate hides whether it inserted or updated the row. The wasRecentlyCreated boolean, set during save, tells the controller which of the two happened so the audit trail is written once.

Upsert changes a thousand rows in one statement

A spreadsheet of 1,000 instalments is 1,000 inserts if every row goes on its own, and 1,000 round trips through the database driver. upsert() sends them as one statement: the first array is every row to write, the second names the columns that make a row unique, and the third names the columns to overwrite when a matching row already exists. Laravel’s own documentation calls it “a single, atomic operation” that updates or inserts in one call. The probe below builds 1,000 rows (100 students over 10 months), upserts them twice and reads back the query log.

upsert: A single SQL statement that writes many rows: the database matches each row against a unique index and either inserts it or writes the given columns over the matching row. takes one statement whatever the number of rows:

One thousand rows upserted, in one query, twice Python · upsert_fees_session.py
from session import run

# upsert() sends every row of the array in one statement, matching on the unique columns of the
# second argument and updating only the columns of the third. The probe builds 1,000 instalments
# for 100 students over 10 months, upserts them twice, and prints the query count each time.
run(
    """
php artisan migrate --no-interaction
php upsert_fees_probe.php
""",
    app=True,
    overlay=("pathshala-model", "upsert-fees"),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_05_090000_create_students_table ............................... DONE
  2026_01_05_091000_create_fee_instalments_table ........................ DONE
$ php upsert_fees_probe.php
Rows to upsert: 1000
Queries run by the first upsert: 1
Statement: insert into "fee_instalments" ("amount_paise", "created_at", "due_on", "status", "student_id", "updated_at") values ...
Rows in fee_instalments: 1000

Second upsert, with changed amounts:
Queries run: 1
Rows in fee_instalments: 1000
Updated amount of (student 1, due 2026-01-05): 500100 paise

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 upsert_fees_session.py

The query log printed Queries run by the first upsert: 1 and Rows in fee_instalments: 1000, which is the whole promise: one statement, every row written. The second call changed every amount from 500000 to 500100 paise, and still ran one query; the row of student 1 due on 2026-01-05 came back with 500100 paise, so the matching rows were updated instead of duplicated.

Key idea

upsert($rows, $uniqueBy, $updateColumns) is for row sets that come from outside, like a payroll upload or a daily sync: one statement a batch, the matching columns pinned by the migration’s unique index, the overwrite controlled by naming exactly the columns the import may touch.

The database decides what “matching” means. The documentation notes that every driver except SQL Server needs the columns of the second argument to have a primary or unique index, and MySQL and MariaDB ignore the second argument and read every unique index of the table. Pin the match down with the index you declared.

Checkpoint

Check: Upsert in one statement

  1. Question 1 of 2 In the recorded upsert session, how many queries did the first upsert of 1,000 rows run?

    Type a number.

    A hint

    Read the line "Queries run by the first upsert" of the session output.

    Show the answer: Question 1 of 2

    Answer: 1

    upsert sends every row in one statement, so DB::getQueryLog() held one entry for the whole call. The second call changed every amount and still ran one query.

  2. Question 2 of 2 You call FeeInstalment::upsert($rows, ['student_id', 'due_on'], ['amount_paise']) and the fee_instalments table has no unique index on (student_id, due_on). On PostgreSQL, what happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The call fails, because the driver needs a primary or unique index on the match columns

    Upsert's match is a database-level operation. The migration must give the match columns a unique index first, which is exactly what this lesson's unique(['student_id', 'due_on']) does.

Soft deletes keep the row and let you restore it

An accountant who deletes an instalment should still be able to put it back; auditors want to see what was deleted and when. Laravel’s SoftDeletes trait writes the current time into a deleted_at column instead of running a DELETE: every default query of the model adds where deleted_at is null, so a trashed row drops out of it, and withTrashed() and onlyTrashed() read the trashed rows when the code asks for them.

soft delete: Marking a row as deleted with a deleted_at time instead of removing it. Normal queries and route binding skip such rows, and they can be restored. More in “Controllers and route model binding” is on FeeInstalment from the top of this lesson: the migration added softDeletes(), which is the Blueprint helper for a nullable deleted_at column, and the model used the SoftDeletes trait. One row through its states:

One row through delete, restore and forceDelete Python · soft_delete_session.py
from session import run

# The SoftDeletes trait keeps the row and sets a deleted_at timestamp instead of removing it;
# the default query ignores soft-deleted rows, withTrashed() reads them, restore() clears the
# timestamp, and forceDelete() removes the row for good.
run(
    """
php artisan migrate --no-interaction
php soft_delete_probe.php
""",
    app=True,
    overlay=("pathshala-model", "soft-delete"),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_05_090000_create_students_table ............................... DONE
  2026_01_05_091000_create_fee_instalments_table ........................ DONE
$ php soft_delete_probe.php
All rows at start:               3

After delete() on the April row:
  default query:                 2 rows
  withTrashed():                 3 rows
  onlyTrashed():                 1 row
  deleted_at of the April row:   2026-04-10 09:15:00

After restore() on the April row:
  default query:                 3 rows
  deleted_at of the April row:   NULL

After forceDelete() on the April row:
  default query:                 2 rows
  withTrashed() still finds it:  false

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 soft_delete_session.py

The default count fell from 3 to 2 after delete(), withTrashed()->count() kept 3 and onlyTrashed()->count() returned 1. deleted_at of the April row read 2026-04-10 09:15:00, which is the test clock the probe set with Carbon::setTestNow for a repeatable run. restore() wrote NULL back into the column and the row returned to every default query; forceDelete() ran the real DELETE and even withTrashed() could no longer find it.

Interactive · step by step One row through delete, restore and forceDelete Step through what Eloquent does to the April row at every move the probe made.
One row through delete, restore and forceDelete, step 6 of 6. Showing Application code, April instalment (row removed), Default query (counts drop by one), withTrashed() (full set returned). In focus: April instalment.writes the columnreadsreadsApplication codeApril instalmentrow removedDefault querycounts drop by onewithTrashed()full set returnedDELETE row

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The row at the start. The April instalment sits in the fee_instalments table with deleted_at set to NULL. The default query returns it and withTrashed() returns it too, because no row is trashed yet.
  2. delete() writes the timestamp. $instalment->delete() does not remove the row. It writes the current time into deleted_at, so the row stays in the table but every query that uses the default scope will skip it.
  3. Default query drops it. FeeInstalment::count() returns one row less, because the SoftDeletes trait adds WHERE deleted_at IS NULL to every default query it writes.
  4. withTrashed() still finds it. FeeInstalment::withTrashed()->count() returns the full set, including the April row. onlyTrashed() returns the April row by itself, which is what a trash bin page would read.
  5. restore() clears the timestamp. FeeInstalment::withTrashed()->find($id)->restore() writes NULL back into deleted_at. The row is in the default query again and withTrashed() sees it, with no history that it was ever gone.
  6. forceDelete() removes the row. $instalment->forceDelete() runs a DELETE statement for real. Neither query returns the April row now, and the audit history goes with it, so forceDelete is for rows a policy says must leave the database.

Three scopes for three audiences

A page for the office uses the default query and sees current rows only. A trash bin page for the office uses onlyTrashed(). An auditor’s report uses withTrashed() and shows every row that ever existed.

Checkpoint

Check: Soft deletes and restore

  1. Question 1 of 2 After $instalment->delete() on a soft-deletable model, which query still returns the row?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: FeeInstalment::withTrashed()->find($id)

    SoftDeletes adds WHERE deleted_at IS NULL to every default query. withTrashed and onlyTrashed read a trashed row explicitly, and restore() brings one back into the default query.

  2. Question 2 of 2 What is the difference between delete() and forceDelete() on a SoftDeletes model?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: delete sets deleted_at to the current time; forceDelete runs a DELETE statement for real

    Soft delete is reversible, forceDelete is not. Use delete for rows an operator may want back; use forceDelete for rows a policy says must leave the database (expired tokens, personal data a visitor asked to remove).

UUID and ULID keys do not replace an authorization check

An auto-increment id in a URL is a page the next integer may reach. If a route serves /students/{student}/fees and the controller trusts the id alone, a visitor with one legitimate link can edit the number and try the next account. The insecure direct object reference (IDOR): Access to a record through a real identifier, such as an id in a URL, that the application loaded but failed to authorize for the current user. More in “Authorization: gates and policies” name is for that pattern, and OWASP’s cheat sheet is a defensive one: the fix is a check on who may read each row, not a bigger number. A key that is hard to guess stops a visitor from reaching the next row by adding one, and hides how many rows the table holds; it does not refuse any request.

The HasUuids trait gives a model a 36-character UUIDv7 primary key, which is lexicographically sortable because UUIDv7 starts with its timestamp. The HasUlids trait does the same with a 26-character ULID: A 26-character identifier made of a 48-bit timestamp and 80 random bits, sortable in the order it was generated, used as a primary key when a sequential integer would make the next row too easy to guess., which is shorter in the URL and still sorts by time:

A model with a ULID primary key PHP · key-overlay/app/Models/OtpSession.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;

// A one-time code sent to a guardian when they open a session. The ULID key keeps the row's
// identifier out of the URL, so a URL on another device cannot be edited to reach another
// guardian's code. The authorization check in GuardianOtpController does the actual refusing.
#[Fillable(['guardian_id', 'code_hash', 'expires_at'])]
class OtpSession extends Model
{
    use HasUlids;
}

A ULID in the URL of a one-time code is a fence, not a lock. The second half of the pair is the controller: it calls an authorization policy before it reads anything about the row, and the policy answers from the authenticated user and the row, not the URL.

Key In a URL Fit
Auto-increment Digits; the next id is one more Internal rows, behind a policy
UUIDv7 36 characters, sorts by time Public rows, longer URL
ULID 26 characters, sorts by time Public rows, shorter URL

Pin the fence to a lock

Put a ULID on the handful of models whose rows appear in URLs sent to visitors (invitations, reset tokens, one-time codes), and keep auto-increment keys everywhere else. Then write the policy that refuses the request whatever the key is: the gates and policies of this track’s authorization module are the tool for it.

Checkpoint

Check: UUID and ULID keys

  1. Question 1 of 2 How long is a ULID written as a string, and what does the HasUlids trait do with it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 26 characters; HasUlids writes one into the primary key column before each insert

    A ULID is a 26-character text encoding of 128 bits (a 48-bit timestamp and 80 random bits), lexicographically sortable because the timestamp comes first. HasUlids fills the key column at insert time.

  2. Question 2 of 2 A page at /otp-sessions/{otpSession} loads a ULID row and shows it. A visitor who signed in opens a colleague's link and reaches the page. What is the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Add an authorization policy that compares the row's guardian_id with the signed-in user

    A ULID is a fence against guessing the next id. The lock is the policy that reads the authenticated user and the row and answers yes or no, before anything is rendered. OWASP's cheat sheet names access control as the cure.

Pruning old rows on a schedule

A guardian one-time code is useful for 10 minutes and then it is clutter. A table of codes two months old is a cost and a liability. The Prunable trait tells a model which of its rows should leave the database over time, and the model:prune Artisan command runs that query on the schedule the application sets.

A prunable model names the rows that may leave the table PHP · prunable-model/OtpSession.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\Builder;

#[Fillable(['guardian_id', 'code_hash', 'expires_at'])]
class OtpSession extends Model
{
    use Prunable;

    /**
     * One-time codes that have been consumed, or have been sitting
     * past their expiry for a day, can leave the table.
     */
    public function prunable(): Builder
    {
        return static::query()->where('expires_at', '<=', now()->subDay());
    }
}

model pruning: A scheduled Artisan task that deletes rows a model marks as no longer needed, so a request that handles today's codes never also has to clean up last month's. runs on the schedule, not inside a request. One line in routes/console.php sets the time of day:

routes/console.php: schedule the prune PHP · prunable-model/console.php
<?php

use App\Models\OtpSession;
use Illuminate\Support\Facades\Schedule;

// routes/console.php: run model:prune every night. Behind the scenes the command walks
// the app/Models folder for Prunable models and calls each one's prunable() query. Only
// the models the --model option names are scheduled when they live outside app/Models.
Schedule::command('model:prune', [
    '--model' => [OtpSession::class],
])->dailyAt('02:30');

model:prune --pretend reports how many rows would leave the table without touching them, which is the test to run before the first scheduled night. Soft-deleted rows that match the prunable query are permanently removed with forceDelete, so a trashed row scheduled for pruning does leave; a prunable method can also return a whereNotNull('deleted_at') to prune only trashed rows.

Checkpoint

Check: Scheduled pruning

  1. Question 1 of 2 What does php artisan model:prune --pretend print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The number of rows that would be removed if the command ran for real

    --pretend is the test night. Run it against production data to see how many rows the prunable query would take before the first scheduled real run.

  2. Question 2 of 2 Where does the Schedule::command('model:prune') call belong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In routes/console.php, scheduled at a quiet time of night

    Pruning is scheduled, so it belongs with the other scheduled tasks in routes/console.php. The command then walks the app/Models folder (or the --model list) for Prunable models and calls each one's prunable query.

Practise: a soft-deletable guardian OTP log, with pruning

Give a copy of your Pathshala project a log of guardian OTPs. The six steps repeat the model, upsert, soft-delete and pruning moves of this page on a table the page never built:

  1. Write a migration for a guardian_otp_log table with id, guardian_id (indexed), code_hash, expires_at, unique(['guardian_id', 'expires_at']), softDeletes() and timestamps(). Run it with php artisan migrate.
  2. Create an App\Models\GuardianOtpLog model that lists guardian_id, code_hash and expires_at under #[Fillable([...])], uses SoftDeletes and casts expires_at to a datetime.
  3. Add Model::shouldBeStrict(! $this->app->isProduction()) to AppServiceProvider::boot(). In Tinker, read a row with select('id') and then try to access code_hash to see the third guard fire.
  4. Upsert a batch of 500 rows from a seed file, using (guardian_id, expires_at) as the unique columns. Enable DB::enableQueryLog() around the call and read count(DB::getQueryLog()): it should be 1.
  5. Soft-delete one row, read it with withTrashed(), restore it, then forceDelete it. Count rows at each step.
  6. Add Prunable to the model with a prunable() method that returns rows whose expires_at is more than a day old, and schedule model:prune --model=App\Models\GuardianOtpLog daily in routes/console.php.

The self-check below asks about strict mode, upserts, soft deletes, keys and pruning, starting with the idea you answer least well, and explains every answer:

Practice that adapts to you

Practise this lesson

12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 A page loads a list of 20 students and prints each student's instalment total. Strict mode is on. Which exception fires first, and what is the smallest change in the controller that stops it?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: LazyLoadingViolationException, fixed by Student::with('instalments')->get()

    Strict mode's first guard watches reads of a relation inside a loop. with('instalments') loads them with one extra query, and the loop then reads from memory.

  2. Question 2 of 12 Why does a model with protected $guarded = [] combined with a controller that calls Model::create($request->all()) hand an attacker a way to set is_admin = 1?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: $guarded = [] turns every column into a writable one, and $request->all() hands over whatever a visitor sent

    $guarded = [] is the inverse of Fillable: it says "no column is protected". Combined with $request->all(), it lets a visitor set any column, which is why this pair is a textbook mass-assignment vulnerability.

  3. Question 3 of 12 A controller writes $notice->title = 'New'; $notice->save(). How does Eloquent decide which columns to UPDATE?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Only columns the code set this request go into the UPDATE statement

    Eloquent compares the model's attributes with the ones it loaded. The dirty set is what save() writes, which is why two parallel requests setting different columns leave both changes in the row.

  4. Question 4 of 12 You run a nightly import of fee instalments and want yesterday's rows to be left alone when their amounts have been corrected by hand. Which method should the import call?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: firstOrCreate on each row

    The three sibling methods differ by what they do when they find a match. firstOrCreate reads; updateOrCreate writes; upsert writes in bulk. For a safe nightly import pick firstOrCreate, or add a WHERE on the status column.

  5. Question 5 of 12 FeeInstalment::upsert($rows, ['student_id', 'due_on'], null) is called. What does the null do?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Every column of the first row is written over the matching row

    The third argument says what to overwrite when a row matches. Passing null means "every column of this row", which overwrites columns the import did not mean to touch; pass the small list of columns the import owns.

  6. Question 6 of 12 You call FeeInstalment::upsert() with 2,000 rows and a sibling call writes a Payment row for each. One payment insert fails halfway. What is the state of the fee_instalments table?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Every row of the upsert is already written; wrap both calls in DB::transaction to roll both back together

    Atomicity has two scopes. upsert is atomic at the SQL statement level, so its rows commit together. Grouping the upsert with the sibling inserts needs an explicit transaction, so a failure anywhere rolls them all back.

  7. Question 7 of 12 A soft-deleted student should take their fee instalments with them, but a restore should bring the instalments back too. Which pattern does that?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: In the Student model, soft-delete its instalments in the deleted event and restore them in the restored event

    Soft delete is a lifecycle, not a cascade. The model's deleted and restored events give the parent a place to walk its children with the same two methods.

  8. Question 8 of 12 fee_instalments has a unique index on (student_id, due_on). Student 7's April instalment is soft-deleted. You try to firstOrCreate the same month for student 7. What happens?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: The unique index catches the trashed row and the insert fails; firstOrCreate finds nothing in its default query, so it tries to insert

    A unique index covers every row the table holds, including trashed ones. If the application allows the row back, the fix is a partial unique index that only applies when deleted_at is null (SQLite and PostgreSQL support that), or a withTrashed read-then-restore step before the firstOrCreate.

  9. Question 9 of 12 A URL /invitations/{invitation} with an auto-increment id is e-mailed to a guardian. Where could the id leak beyond the guardian?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: In server logs, in referrer headers from the e-mail page, and in copies of the e-mail

    A ULID shortens the attack surface against a guesser, who cannot step to the next invitation; it does not shorten any other leak. The authorization check is still the lock.

  10. Question 10 of 12 A controller does Invitation::findOrFail($id) with an id from the URL, then returns the view. Which test proves the IDOR hole and the fix?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Sign in as user B, request user A's invitation URL and assert 403: the test fails with a 200 before the fix and passes after it

    The negative test is the heart of the fix. The authorization policy fires before the controller renders, and the test asserts a 403 for a signed-in user who should not reach that row.

  11. Question 11 of 12 A row is both soft-deletable and prunable, with a prunable query that reads where('deleted_at', '<', now()->subMonths(6)). What happens when model:prune reaches that row?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The row is forceDeleted: the Prunable trait permanently removes trashed rows that match the query

    Pruning does not care whether the row is active or trashed; it reads the prunable query. Pointing the query at old trashed rows is a common pattern for "keep in trash for N months, then remove for good".

  12. Question 12 of 12 You wrote a new prunable method and want to make sure it does not delete too much on the first night. What is the first command to run?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: php artisan model:prune --pretend

    --pretend is the dry run. It prints a count per model and touches nothing, so the operator can read the number before accepting the schedule.

Interview questions

Warm-up (fresher): why does strict mode belong in development and not on the production site?

Because the guards are there to make a developer see a slip early, at the first slow page or the first odd row on a laptop, not to break a live site that has run with a lazy-loaded relation or a stray array key for months. In development each exception is a one-line fix: eager-load with with(), add the key to Fillable, or add the column to the select(). In production the same exception would be an error page a visitor gets instead of the page they asked for. So the usual line is Model::shouldBeStrict(! $this->app->isProduction()): on everywhere except production. The test suite runs with the guards on as well, so most slips are found and fixed before a deploy, while production keeps the default behaviour.

Two longer questions grow out of the Fillable list and the ULID key above. Sketch how you would open each answer before you read the opening step written for it:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A teammate sees protected $guarded = [] on a User model and a controller line User::create(request()->all()). Walk through why this pair is a mass-assignment hole, and the two small changes you would make to close it.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A teammate replaces the primary key of an Invitation model with a ULID and closes a security ticket about an insecure direct object reference. How would you decide whether the hole is closed, and what would you add if not?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which three slips does Model::shouldBeStrict() turn into exceptions?

    Lazy loading of a relationship, writing an array key the model has not declared as fillable, and reading a column the query did not select. Turn it on in development only, so a live page never fails on a slip it has run with for months.

  2. How do firstOrCreate, updateOrCreate and upsert differ?

    firstOrCreate and updateOrCreate write one row each; upsert writes many rows in one statement. All three are repeatable without duplicates only when a unique index matches the columns they search by.

  3. What do the three arguments of upsert($rows, $uniqueBy, $updateColumns) mean?

    The rows to write, the columns that identify an existing row (backed by the migration's unique index), and exactly the columns an existing row may have overwritten. In the probe it wrote 1,000 rows in one query.

  4. What does the SoftDeletes trait change, and which four methods work with deleted rows?

    Deleting writes a time into deleted_at instead of removing the row, and normal queries skip it. withTrashed includes such rows, onlyTrashed reads only them, restore clears the mark, and forceDelete really deletes.

  5. Does a UUID or ULID primary key make a record private?

    No. It keeps the next id from being guessed from a URL, but only an authorization check refuses a visitor the row does not belong to.

Key takeaways

  • A model names one row of a table, and its Fillable list is the column boundary for an array that came from outside, so a request can never write a column no form offered.
  • Model::shouldBeStrict() turns three default behaviours into exceptions in development: lazy loading inside a loop, writing an array key the model has not declared, and reading a column the query did not select.
  • firstOrCreate, updateOrCreate and upsert write one row, one row and many rows respectively, and all three need a matching unique index to be idempotent.
  • upsert($rows, $uniqueBy, $updateColumns) ran one query for 1,000 rows in the probe, which is why the import takes one trip through the driver instead of 1,000.
  • SoftDeletes writes a deleted_at column instead of deleting the row; withTrashed, onlyTrashed, restore and forceDelete are the four methods that read, un-mark or really delete it.
  • A UUID or ULID key is a fence that keeps the next id out of a URL; the authorization check is the lock that refuses a request from a visitor the row does not belong to.

Terms in this lesson

model pruning
A scheduled Artisan task that deletes rows a model marks as no longer needed, so a request that handles today's codes never also has to clean up last month's.
strict mode
A development-only Eloquent setting that turns three default behaviours into exceptions: lazy loading of a relationship, writing a column the model has not declared, and reading a column the query did not select.
ULID
A 26-character identifier made of a 48-bit timestamp and 80 random bits, sortable in the order it was generated, used as a primary key when a sequential integer would make the next row too easy to guess.
upsert
A single SQL statement that writes many rows: the database matches each row against a unique index and either inserts it or writes the given columns over the matching row.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.