This series ends with a team's invoices modelled, related and served through an API. Name the model and its methods for the words the team already uses. Invoice, not Data. lines(), not invoiceLineItems(). A later reader should guess the relationship from the method. The table can stay plural and the foreign key can stay team_id. The names a person says out loud belong on the class.
Eloquent, properly · Lesson 1
Models that match the domain
Video
01 Say what an invoice is allowed to hold
$fillable is the list of attributes a request is allowed to write. Leave the team id off it when the controller sets the team from the signed-in user. A mass-assigned team_id is how an invoice moves to another tenant.
Number, issued date and status are the fields the form is allowed to choose. notes is deliberately left off $fillable. Refuse attributes a model will throw away is the case where that column is added.
class Invoice extends Model
{
protected $fillable = [
'number',
'issued_on',
'status',
];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
}Create it from the team, so the foreign key is set by the relationship rather than by the request. The same shape creates a line. The invoice you are holding writes invoice_id, and the request never sees that column.
$invoice = $team->invoices()->create([
'number' => $data['number'],
'issued_on' => $data['issued_on'],
'status' => 'draft',
]);A reader who opens the class can see both decisions. The relationship is called team(), because an invoice has one team. The fillable list is short, because the form is not the place that chooses a tenant. A factory or a seeder that needs to set team_id can call forceFill() or create through the relationship.
02 Make a discarded team id throw
Eloquent drops a key that is not fillable and carries on. The invoice is stored. team_id stays null, or stays on the old team, and the request looks successful. Outside production, turn that silence into an exception. It sits in the same boot method as the lazy-load guard, and it stays off for customers so a forgotten key degrades instead of failing the request.
public function boot(): void
{
Model::preventSilentlyDiscardingAttributes(! app()->isProduction());
}Post team_id with the rest of the form and the test now fails, naming the attribute the model refused. The fix is not to add team_id to $fillable.
The fix is to stop sending it, and to keep creating the invoice from the team. The guard does not write the foreign key for you. It only stops the column from being thrown away without a trace. In production the same request still discards the key, which is why the relationship, not the guard, is what keeps the tenant.
A job or a command that builds an invoice has the same rule. Load the team, then call invoices()->create(). Copying request input into Invoice::create() is how a queued payload chooses a tenant the signed-in user never belonged to.
Recap
You can name an invoice for the domain, keep team_id off the fillable list, and hear about it when a request tries to set that column anyway.
lines()is the relationship a reader should guess.- Create the invoice from the team so the foreign key is not in the request.
- A fillable
team_idis how an invoice changes tenant. preventSilentlyDiscardingAttributes()throws outside production when a key is dropped.
Check yourself · 3 questions
Question 1 of 3A form posts team_id and the column is fillable. What can a member of Team A do?