<?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('article_tag', function (Blueprint $table) {
$table->id();
$table->foreignId('article_id')->constrained()->cascadeOnDelete();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['article_id', 'tag_id']);
});
}
public function down(): void
{
Schema::dropIfExists('article_tag');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Article extends Model
{
protected $fillable = ['title', 'body', 'published_at'];
protected $casts = [
'published_at' => 'datetime',
];
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Tag extends Model
{
protected $fillable = ['name', 'slug'];
public function articles(): BelongsToMany
{
return $this->belongsToMany(Article::class)->withTimestamps();
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ArticleController extends Controller
{
public function update(Request $request, Article $article): JsonResponse
{
$validated = $request->validate([
'title' => ['sometimes', 'string', 'max:255'],
'body' => ['sometimes', 'string'],
'tags' => ['array'],
'tags.*' => ['integer', 'exists:tags,id'],
]);
$article->fill($validated)->save();
$changes = $article->tags()->sync($validated['tags'] ?? []);
return response()->json([
'article' => $article->load('tags'),
'synced' => $changes,
]);
}
}
This snippet shows how a blog assigns tags to articles using Eloquent's many-to-many relationship and the sync method, which is the idiomatic way to reconcile a set of related records against a pivot table in a single call. The example spans a migration that defines the join table, two models that declare the relationship, and a controller that persists the selection.
In create_article_tag_table migration, the pivot table article_tag follows Laravel's naming convention: the two related tables in singular, alphabetical order. Each foreign key uses constrained() with cascadeOnDelete() so removing an article or a tag automatically clears the corresponding pivot rows, avoiding orphaned links. A composite unique index on article_id and tag_id enforces at the database level that the same tag can only attach once, which protects against duplicate rows even if application code slips.
In Article model, the tags() method returns a belongsToMany relation. Because the migration follows conventions, no extra pivot-table or key arguments are needed. The withTimestamps() call tells Eloquent to maintain created_at/updated_at on the pivot rows, which is useful for auditing when a tag was applied. Tag model declares the inverse articles() relation so the graph can be traversed from either side.
The interesting logic lives in ArticleController. The update method validates that tags is an array and that every element is the id of a real row via the exists rule, which prevents attaching phantom tags. The core call is $article->tags()->sync($validated['tags'] ?? []). Unlike attach, which only adds, sync computes the difference: it inserts missing links, removes links no longer present, and leaves untouched ones alone, returning an array of what was attached, detached, and updated. Passing an empty array detaches everything, which is why the ?? [] fallback matters when no tags are submitted.
A common alternative is syncWithoutDetaching, used when tags should only ever be added; sync is the right choice here because the form represents the complete desired state. Wrapping related writes and eager-loading with load('tags') afterward keeps the response consistent and avoids an N+1 query when rendering the result.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.