Skip to main content

Polymorphic Notes: One Table for Comments Across Multiple Models

Every application eventually needs a notes feature. Properties need notes. Tenants need notes. Work orders need notes. The instinct is to create separate tables: property_notes, tenant_notes, work_order_notes.

Don't do that.

Laravel's polymorphic relationships let you create one notes table that can attach to any model. One migration, one model class, one set of queries to maintain. When you add a new noteable model next month, you just add a relationship - no schema changes.

The Scenario: Property Management

A property management company tracks various entities:

  • Properties - Buildings and units under management
  • Tenants - Current and past occupants
  • Work Orders - Maintenance requests and repairs
  • Leases - Active rental agreements

Staff need to add notes to all of these. A polymorphic notes system lets them do that with one unified feature.

The Schema

A polymorphic table needs two columns to identify what it's attached to:

Schema::create('notes', function (Blueprint $table) {
    $table->id();
    $table->morphs('noteable');
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->text('content');
    $table->boolean('is_private')->default(false);
    $table->timestamps();
    $table->softDeletes();
});

The morphs() helper creates:

  • noteable_type - The class name of the parent model (App\Models\Property)
  • noteable_id - The ID of the specific record

Together they form a "pointer" to any model in your system.

The Note Model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;

class Note extends Model
{
    use SoftDeletes;

    protected $fillable = ['noteable_id', 'noteable_type', 'user_id', 'content', 'is_private'];

    protected function casts(): array
    {
        return ['is_private' => 'boolean'];
    }

    public function noteable(): MorphTo
    {
        return $this->morphTo();
    }

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

The morphTo() relationship is the inverse side. It lets you access the parent model without knowing what type it is:

$note = Note::find(1);
$parent = $note->noteable;  // Could be Property, Tenant, WorkOrder, etc.

Adding Notes to Models

Rather than repeating the same relationship on every model, extract it into a trait:

trait HasNotes
{
    public function notes(): MorphMany
    {
        return $this->morphMany(Note::class, 'noteable')->latest();
    }
}

Then apply it to any model that needs notes:

class Property extends Model
{
    use HasNotes;
}

class Tenant extends Model
{
    use HasNotes;
}

class WorkOrder extends Model
{
    use HasNotes;
}

The morphMany() method takes the Note class and the relationship name (noteable). Laravel looks for noteable_type and noteable_id columns automatically.

Creating Notes

// Via the relationship (preferred)
$property->notes()->create([
    'user_id' => auth()->id(),
    'content' => 'Inspected unit 4B. Minor water damage near kitchen window.',
    'is_private' => true,
]);

// Or directly
Note::create([
    'noteable_type' => Property::class,
    'noteable_id' => $property->id,
    'user_id' => auth()->id(),
    'content' => 'Inspected unit 4B.',
]);

The relationship approach is cleaner - you don't need to specify the type and ID manually.

Querying Notes

Get all notes for a model:

$property->notes;

Get notes with authors eager-loaded:

$property->load('notes.author');

foreach ($property->notes as $note) {
    echo "{$note->author->name}: {$note->content}";
}

Filter out private notes for non-staff users:

$property->notes()
    ->when(!auth()->user()->isStaff(), fn ($q) => $q->where('is_private', false))
    ->with('author')
    ->get();

Find all notes by a specific user across all models:

Note::where('user_id', $userId)->with('noteable')->get();

Find all notes for properties specifically:

Note::where('noteable_type', Property::class)->get();

Displaying Notes

@forelse($property->notes as $note)
    <article>
        <header>
            <strong>{{ $note->author->name }}</strong>
            <time>{{ $note->created_at->diffForHumans() }}</time>
            @if($note->is_private)
                <span>Staff Only</span>
            @endif
        </header>
        <p>{{ $note->content }}</p>
    </article>
@empty
    <p>No notes yet.</p>
@endforelse

A Livewire Component

<?php

namespace App\Livewire\Notes;

use App\Models\Note;
use Livewire\Component;

class CreateNote extends Component
{
    public string $noteableType;
    public int $noteableId;
    public string $content = '';
    public bool $isPrivate = false;

    public function mount(string $noteableType, int $noteableId): void
    {
        $this->noteableType = $noteableType;
        $this->noteableId = $noteableId;
    }

    public function save(): void
    {
        $this->validate(['content' => 'required|min:3|max:5000']);

        Note::create([
            'noteable_type' => $this->noteableType,
            'noteable_id' => $this->noteableId,
            'user_id' => auth()->id(),
            'content' => $this->content,
            'is_private' => $this->isPrivate,
        ]);

        $this->reset(['content', 'isPrivate']);
        $this->dispatch('note-created');
    }

    public function render()
    {
        return view('livewire.notes.create-note');
    }
}

The template:

<form wire:submit="save">
    <textarea wire:model="content" placeholder="Add a note..."></textarea>
    @error('content') <p>{{ $message }}</p> @enderror

    @if(auth()->user()->isStaff())
        <label>
            <input type="checkbox" wire:model="isPrivate"> Staff only
        </label>
    @endif

    <button type="submit">Add Note</button>
</form>

Use it on any page:

<livewire:notes.create-note 
    :noteable-type="App\Models\Property::class" 
    :noteable-id="$property->id" 
/>

Events and Notifications

Since all notes flow through one model, you can handle events centrally:

class NoteObserver
{
    public function created(Note $note): void
    {
        if ($note->is_private) {
            return;
        }

        $recipient = match ($note->noteable::class) {
            Property::class => $note->noteable->manager,
            Tenant::class => $note->noteable->agent,
            WorkOrder::class => $note->noteable->technician,
            default => null,
        };

        $recipient?->notify(new NoteAdded($note));
    }
}

When to Use Polymorphic Relationships

This pattern works well when:

  • Multiple models need the same type of related data
  • The related data has the same structure regardless of parent
  • You want centralized handling (events, notifications, queries)

Consider separate tables when:

  • Different parents need different note fields
  • You need foreign key constraints (polymorphic relationships can't use them)
  • Query performance is critical and you need targeted indexes

For most notes/comments features, polymorphic relationships are the cleaner choice. The trade-off (no foreign key constraints) is usually worth the simplicity of a single notes system.