A teammate saves an invoice, the request returns 500, and the log says MassAssignmentException: Add [lines] to fillable property to allow mass assignment on [App\Models\Invoice]. The lines were never meant to be columns. They were an array on the same form, and $request->validated() handed that array to update() along with the invoice fields. With the discard guard on, Eloquent refuses to throw the array away.

A form request is the boundary for one write. It decides who may submit, what the input is allowed to look like, and which of that input belongs on the model. The controller should receive data that is already valid, and it should not pass a nested array to a model that has no such column.

01 Decide who is allowed in

authorize() runs before the rules. Return false and Laravel stops with a 403. The rules never run, and the controller never runs. That is the right place for "may this user update this invoice?", because a failed permission check is not a validation error.

UpdateInvoiceRequest.phpPHP
// app/Http/Requests/UpdateInvoiceRequest.php
namespace App\Http\Requests;

use App\Models\Invoice;
use Illuminate\Foundation\Http\FormRequest;

class UpdateInvoiceRequest extends FormRequest
{
    public function authorize(): bool
    {
        $invoice = $this->route('invoice');

        return $invoice instanceof Invoice
            && ($this->user()?->can('update', $invoice) ?? false);
    }

    /**
     * @return array<string, array<int, string>>
     */
    public function rules(): array
    {
        return [
            'number' => ['required', 'string', 'max:32'],
            'issued_on' => ['required', 'date'],
            'notes' => ['nullable', 'string', 'max:2000'],
            'lines' => ['required', 'array', 'min:1'],
            'lines.*.description' => ['required', 'string', 'max:200'],
            'lines.*.quantity' => ['required', 'integer', 'min:1'],
            'lines.*.unit_amount' => ['required', 'integer', 'min:0'],
        ];
    }
}

The policy still owns the team check. authorize() only asks it. If the route parameter is missing, treat that as a refusal rather than calling can() on null.

02 The array is not one model

The naive controller takes everything that passed and writes it onto the invoice.

InvoiceController.phpPHP
// app/Http/Controllers/InvoiceController.php
use App\Http\Requests\UpdateInvoiceRequest;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;

class InvoiceController
{
    public function update(UpdateInvoiceRequest $request, Invoice $invoice): RedirectResponse
    {
        $invoice->update($request->validated());

        return back();
    }
}

validated() includes lines. Invoice has no lines column and, if you followed Refuse attributes a model will throw away, Model::preventSilentlyDiscardingAttributes() is on outside production. Eloquent throws instead of dropping the key. In production, with the guard off, it drops lines silently, the invoice number changes, and the line items in the form are never written. Either way the customer did not get what they submitted.

safe() is the validated input as an object. except('lines') removes the nested array. The lines are still in validated('lines'), and they are written through the relationship, which sets invoice_id for you.

InvoiceController.phpPHP
// app/Http/Controllers/InvoiceController.php
use App\Http\Requests\UpdateInvoiceRequest;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;

class InvoiceController
{
    public function update(UpdateInvoiceRequest $request, Invoice $invoice): RedirectResponse
    {
        DB::transaction(function () use ($request, $invoice): void {
            $invoice->update($request->safe()->except('lines'));

            $invoice->lines()->delete();
            $invoice->lines()->createMany($request->validated('lines'));
        });

        return back();
    }
}

Replacing the lines is a deliberate choice for this form, which always submits the full list. A form that edits one line should not delete the others. The point of except('lines') is the same either way: the invoice model only receives attributes it can store.

Check yourself · 1 question

Question 1 of 1The invoice form submits lines, and the model has preventSilentlyDiscardingAttributes() on. The controller calls update() with the whole validated() array. What happens outside production?

03 Normalise before the rules

prepareForValidation() is for small normalisations: trim a string, turn an empty string into null, merge a default the rules can then check. It runs before rules(), so the value you merge is the value the rules see.

Empty notes are a common case. A textarea posts "". A nullable column wants null. Merge that conversion, and give notes a rule. Without the rule, validated() and safe() omit notes entirely. The merge looks like it worked, and the column never changes.

UpdateInvoiceRequest.phpPHP
// app/Http/Requests/UpdateInvoiceRequest.php
class UpdateInvoiceRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        $notes = $this->input('notes');

        $this->merge([
            'number' => trim((string) $this->input('number')),
            'notes' => $notes === '' ? null : $notes,
        ]);
    }
}

The matching rule is already in rules() above: 'notes' => ['nullable', 'string', 'max:2000']. Nullable allows the null you just merged. Max still rejects a novel pasted into the field.

Keep this method boring. A price calculation, a stock check, or "the total must match the lines" is not preparation. Those are rules, or they belong in the action the controller calls after validation.

04 Check fields against each other

after() adds checks that are awkward in a rules() array. It runs after the rules have passed, and it receives a Validator you can add errors to. An error added here is a normal 422, with the field name you choose. The method has been on form requests since Laravel 10.

UpdateInvoiceRequest.phpPHP
// app/Http/Requests/UpdateInvoiceRequest.php
use App\Models\Invoice;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;

class UpdateInvoiceRequest extends FormRequest
{
    public function after(): array
    {
        return [
            function (Validator $validator): void {
                $invoice = $this->route('invoice');
                $issued = $this->date('issued_on');

                if (! $invoice instanceof Invoice || $issued === null) {
                    return;
                }

                if ($issued->lt($invoice->team->created_at)) {
                    $validator->errors()->add(
                        'issued_on',
                        'The invoice cannot predate the team.',
                    );
                }
            },
        ];
    }
}

date() returns null when the field is missing or not a date, so the closure has to tolerate that. The issued_on rule already rejects a missing date. The closure is there for the comparison the rule array cannot see, because the team's created_at is not part of the request.

Check yourself · 1 question

Question 1 of 1prepareForValidation() turns an empty notes string into null, and notes has no rule. What does the controller receive?

05 Edge cases

authorize() and validation fail differently. A policy refusal is 403. A rule failure is 422, and the user can fix the form. Do not return false from authorize() for a bad date. The user will see a forbidden page for a typo.

after() does not replace rules. If you add an error for issued_on and the date rule already failed, the user can see both messages. Return early when date() is null so you do not add a second, confusing message on top of "the issued on field must be a valid date".

safe()->except('lines') does not remove lines from the request. A later $request->validated('lines') still returns the array. except only changes the array you pass to the model.

createMany() expects a list of attribute arrays and mass-assigns each one. InvoiceLine needs description, quantity and unit_amount fillable, and it should not have invoice_id fillable if the relationship sets it. The same discard guard will throw if a line array contains a key the line model does not allow.

Replacing every line inside a transaction matters. delete() then createMany() without a transaction can leave the invoice with no lines if the second statement throws. The controller above wraps both writes.

A form request is resolved from the container when the controller method is called. You cannot new UpdateInvoiceRequest in a test and expect authorize() to see the route. Send the HTTP request, or call $this->app->instance() only when you are testing the class in isolation and you have set the route yourself.

06 Testing it

The date check is the one that is easy to break when someone later "simplifies" after(). Freeze nothing. Create a team in the past, post an issued date before that, and assert the field.

UpdateInvoiceTest.phpPHP
// tests/Feature/UpdateInvoiceTest.php
use App\Models\Invoice;
use App\Models\Team;
use App\Models\User;

it('rejects an invoice dated before the team existed', function () {
    $team = Team::factory()->create(['created_at' => '2026-01-01']);
    $invoice = Invoice::factory()->for($team)->create();
    $user = User::factory()->create(['current_team_id' => $team->id]);

    $this->actingAs($user)
        ->put(route('invoices.update', $invoice), [
            'number' => 'INV-1',
            'issued_on' => '2025-12-01',
            'notes' => '',
            'lines' => [
                ['description' => 'Support', 'quantity' => 1, 'unit_amount' => 1000],
            ],
        ])
        ->assertInvalid(['issued_on']);

    expect($invoice->fresh()->number)->not->toBe('INV-1');
});

assertInvalid(['issued_on']) fails if the closure stops adding the error. The second expectation fails if the controller wrote the invoice before validation finished. A second test, with a date after the team was created, should assert the number changed and one line exists. That is the path that catches except('lines') being removed.

Check yourself · 1 question

Question 1 of 1You want to reject an issued date that falls before the team was created. Where does that check belong?

07 Checklist

  • authorize() asks the policy. It does not reimplement the team check, and it does not reject bad input.
  • Every field you merge in prepareForValidation(), including notes, has a rule. Otherwise it never appears in validated().
  • Pass $request->safe()->except('lines') to the invoice. Write lines through the relationship.
  • after() is for comparisons the rule array cannot see. It needs use Illuminate\Validation\Validator and Laravel 10 or newer.
  • Wrap the invoice update and the line writes in one transaction.
  • A Pest test posts a date before the team existed and asserts issued_on is invalid, and that the row did not change.

08 Further reading