Files
usher-manage-stack/.planning/phases/01-database-schema-backend-api/01-RESEARCH.md

29 KiB

Phase 01: Database Schema & Backend API - Research

Researched: 2026-02-13 Domain: Laravel 10 polymorphic relationships, database schema design, RESTful API endpoints Confidence: HIGH

Summary

Phase 1 involves creating a polymorphic notes table that can attach to multiple entity types (starting with Members, extensible to future entities like Issues or Finance documents). The implementation follows Laravel 10's standard morphMany/morphTo relationship pattern with proper composite indexing for performance. Admin endpoints will return Blade views (consistent with existing admin controllers), not JSON API responses.

Primary recommendation: Use Laravel's native polymorphic relationship methods (morphMany/morphTo) with composite index on [notable_type, notable_id] columns, implement eager loading with withCount('notes') to prevent N+1 queries, and use Form Request classes for validation following existing codebase patterns.

Standard Stack

Core

Library Version Purpose Why Standard
Laravel Framework 10.50.0 Backend framework Existing codebase version
PHP 8.1+ Runtime Project requirement
Spatie Laravel Permission ^5.10 RBAC authorization Already integrated for role/permission checks

Supporting

Library Version Purpose When to Use
Laravel Debugbar dev only N+1 query detection Development environment to verify no performance issues

Alternatives Considered

Instead of Could Use Tradeoff
Polymorphic relationship Separate notes tables per entity Polymorphic provides extensibility; separate tables harder to maintain as features grow
Form Request classes Inline validation Form Requests align with existing codebase pattern (see StoreMemberRequest)
Blade views API JSON responses Admin controllers return views (consistent with ArticleController, DocumentController patterns)

Installation: No new packages required - uses Laravel core features.

Architecture Patterns

app/
├── Models/
│   ├── Note.php                    # New polymorphic child model
│   └── Member.php                  # Add morphMany relationship
├── Http/
│   ├── Controllers/
│   │   └── Admin/
│   │       └── MemberNoteController.php  # New resource controller
│   └── Requests/
│       ├── StoreNoteRequest.php    # Validation for creating notes
│       └── UpdateNoteRequest.php   # Validation for updating notes
└── database/
    └── migrations/
        └── YYYY_MM_DD_HHMMSS_create_notes_table.php

tests/
└── Feature/
    └── Admin/
        └── MemberNoteTest.php      # Feature tests for note CRUD

Pattern 1: Polymorphic One-to-Many Relationship

What: Notes belong to multiple entity types (Member, future Issue, etc.) using notable_type and notable_id columns When to use: When a child model (Note) can attach to multiple parent model types Example:

// Source: https://laravel.com/docs/10.x/eloquent-relationships (Polymorphic Relationships section)

// app/Models/Note.php
class Note extends Model
{
    protected $fillable = [
        'notable_type',
        'notable_id',
        'content',
        'author_user_id',
    ];

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

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

// app/Models/Member.php
class Member extends Model
{
    public function notes(): MorphMany
    {
        return $this->morphMany(Note::class, 'notable');
    }
}

// Usage
$member = Member::find(1);
$notes = $member->notes;  // Returns all notes for this member

Pattern 2: Composite Index for Polymorphic Lookups

What: Single index on both notable_type and notable_id columns together When to use: Always for polymorphic relationships - both columns are required for queries Example:

// Source: https://roelofjanelsinga.com/articles/improve-performance-polymorphic-relationships-laravel/

Schema::create('notes', function (Blueprint $table) {
    $table->id();
    $table->morphs('notable');  // Creates notable_type, notable_id, and composite index
    // Alternatively, manual approach:
    // $table->string('notable_type');
    // $table->unsignedBigInteger('notable_id');
    // $table->index(['notable_type', 'notable_id']); // Composite index - order matters!

    $table->longText('content');
    $table->foreignId('author_user_id')->constrained('users')->cascadeOnDelete();
    $table->timestamps();

    // Additional indexes for sorting/filtering
    $table->index('created_at');
});

Pattern 3: Prevent N+1 with withCount()

What: Eager load relationship counts in a single query When to use: When displaying lists with relationship counts (member index showing note count) Example:

// Source: Existing codebase app/Http/Controllers/Admin/ArticleCategoryController.php

// Admin controller - member list
public function index(Request $request)
{
    // withCount prevents N+1 by adding notes_count column via subquery
    $members = Member::withCount('notes')
        ->orderBy('created_at', 'desc')
        ->paginate(20);

    return view('admin.members.index', compact('members'));
}

// In Blade view
@foreach($members as $member)
    <td>{{ $member->notes_count }}</td>  <!-- No additional query -->
@endforeach

Pattern 4: Form Request Validation

What: Separate validation classes with authorize() and rules() methods When to use: All admin controller actions that modify data Example:

// Source: Existing codebase app/Http/Requests/StoreMemberRequest.php

// app/Http/Requests/StoreNoteRequest.php
class StoreNoteRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Reuse existing admin middleware - all admin users can write notes
        return $this->user()->hasRole('admin')
            || !$this->user()->getAllPermissions()->isEmpty();
    }

    public function rules(): array
    {
        return [
            'content' => 'required|string|min:1',
            'notable_type' => 'required|string|in:App\Models\Member',
            'notable_id' => 'required|integer|exists:members,id',
        ];
    }
}

// Controller usage
public function store(StoreNoteRequest $request)
{
    $validated = $request->validated();
    // Validation already passed, data is clean
}

Pattern 5: Audit Logging for Note Actions

What: Manual audit log calls in controller actions (not observers) When to use: After successful data mutations Example:

// Source: Existing codebase app/Http/Controllers/BudgetController.php

use App\Support\AuditLogger;

public function store(StoreNoteRequest $request)
{
    $note = Note::create([
        'notable_type' => $request->notable_type,
        'notable_id' => $request->notable_id,
        'content' => $request->content,
        'author_user_id' => $request->user()->id,
    ]);

    AuditLogger::log('note.created', $note, [
        'notable_type' => $request->notable_type,
        'notable_id' => $request->notable_id,
        'author' => $request->user()->name,
    ]);

    return redirect()->back()->with('status', '備忘錄已新增');
}

Pattern 6: Admin Route Registration

What: Group admin routes under /admin prefix with auth and admin middleware When to use: All administrative functionality Example:

// Source: Existing codebase routes/web.php

Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
    // Nested resource route for member notes
    Route::resource('members.notes', MemberNoteController::class)
        ->only(['store', 'update', 'destroy']);
});

// Routes generated:
// POST   /admin/members/{member}/notes          → admin.members.notes.store
// PATCH  /admin/members/{member}/notes/{note}   → admin.members.notes.update
// DELETE /admin/members/{member}/notes/{note}   → admin.members.notes.destroy

Anti-Patterns to Avoid

  • Magic strings for status values: Use class constants like Note::STATUS_ACTIVE, not hardcoded strings
  • Single-column indexes on polymorphic tables: Always use composite [type, id] index, not separate indexes
  • Lazy loading in lists: Always use withCount() or with() to eager load relationships
  • Inline validation: Use Form Request classes consistently with existing codebase
  • API resources for admin endpoints: Admin controllers return Blade views, not JSON (only /api/v1/* routes use API resources)
  • Automatic audit logging: This codebase calls AuditLogger::log() manually in controllers, not via model observers

Don't Hand-Roll

Problem Don't Build Use Instead Why
Polymorphic relationships Custom join tables with type columns Laravel morphMany/morphTo Handles fully qualified class names, supports eager loading, integrates with query builder
N+1 query prevention Manual caching or query optimization withCount(), with() eager loading Laravel optimizes to single subquery, works with pagination
Polymorphic indexes Separate indexes on _type and _id $table->morphs('notable') or composite index(['type', 'id']) MySQL/PostgreSQL can only use composite index when querying both columns together
Request validation Try/catch with manual validation Form Request classes with authorize() and rules() Automatic 422 responses, clean controller code, reusable validation logic
Audit logging Custom logging to text files AuditLogger::log($action, $model, $metadata) Already implemented, stores structured JSON metadata, searchable in database

Key insight: Laravel's polymorphic relationship methods handle edge cases like proper class name storage (can use morph map to decouple), query scoping, and eager loading optimizations. Composite indexing is critical because MySQL/PostgreSQL cannot use separate indexes for queries requiring both notable_type = 'X' AND notable_id = Y.

Common Pitfalls

Pitfall 1: Missing Composite Index on Polymorphic Columns

What goes wrong: Queries for notes on a specific member become slow (full table scans) when notes table grows beyond ~10,000 records Why it happens: Developers create separate indexes on notable_type and notable_id instead of a single composite index How to avoid: Use $table->morphs('notable') which automatically creates the composite index, or manually use $table->index(['notable_type', 'notable_id']) Warning signs: Slow member detail pages when loading notes, database query time over 100ms for note lookups

Source: Roelof Jan Elsinga - Improve query performance for polymorphic relationships

Pitfall 2: N+1 Queries When Displaying Note Counts

What goes wrong: Member list page makes 1 query to fetch members + N queries to count notes for each member (21 queries for 20 members) Why it happens: Blade views access $member->notes->count() without eager loading the count in the controller How to avoid: Use Member::withCount('notes') in controller, then access $member->notes_count in Blade (single subquery added to initial fetch) Warning signs: Laravel Debugbar shows N queries all with same structure (SELECT COUNT(*) FROM notes WHERE notable_id = ?), page load time increases linearly with pagination size

Source: Yellow Duck - Laravel's Eloquent withCount method

Pitfall 3: Wrong Column Order in Composite Index

What goes wrong: Composite index ['notable_id', 'notable_type'] is not used by queries filtering on both columns Why it happens: MySQL uses leftmost prefix rule - index only applies if query filters leftmost column first How to avoid: Always put notable_type first in composite index: ['notable_type', 'notable_id'] because queries always filter by type AND id together Warning signs: EXPLAIN query shows "type: ALL" (full table scan) even with composite index present

Source: René Roth - Composite indexes in Laravel & MySQL

Pitfall 4: Self-Approval in Author Verification

What goes wrong: Note author can modify or delete their own notes without proper authorization checks Why it happens: Controller checks if($user->can('edit_notes')) but doesn't verify $note->author_user_id != $user->id How to avoid: Based on existing codebase pattern with HasApprovalWorkflow trait, implement similar check in controller authorization or use Laravel policies Warning signs: Audit trail shows notes modified by original author when business rule requires separate approver

Source: Existing codebase pattern in app/Traits/HasApprovalWorkflow.php (self-approval prevention)

Pitfall 5: Missing Database Transactions for Multi-Step Operations

What goes wrong: Note created successfully but audit log fails, leaving incomplete audit trail Why it happens: Controller creates note and logs audit in separate operations without transaction wrapper How to avoid: Wrap related operations in DB::transaction() closure - both succeed or both rollback Warning signs: Database contains notes without corresponding audit_logs entries

Source: Laravel Daily - Database Transactions: 3 Practical Examples

Pitfall 6: Storing Full Class Names in notable_type Without Morph Map

What goes wrong: Refactoring model namespace from App\Models\Member to App\Domain\Members\Member breaks all existing polymorphic relationships Why it happens: Laravel stores fully qualified class name by default; database contains hardcoded App\Models\Member strings How to avoid: Define morph map in AppServiceProvider::boot() using Relation::enforceMorphMap(['member' => Member::class]) - stores 'member' string instead of class name Warning signs: After namespace refactor, queries return empty results for previously created polymorphic relations

Source: Laravel 10 Eloquent Relationships - Custom Polymorphic Types

Code Examples

Verified patterns from official sources and existing codebase:

Migration for Notes Table

// Source: Laravel 10 migration patterns + existing codebase database/migrations/2026_02_07_120002_create_articles_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('notes', function (Blueprint $table) {
            $table->id();

            // Polymorphic relationship columns with composite index
            $table->morphs('notable');  // Creates notable_type, notable_id, and index(['notable_type', 'notable_id'])

            $table->longText('content');
            $table->foreignId('author_user_id')->constrained('users')->cascadeOnDelete();

            $table->timestamps();

            // Additional index for chronological sorting
            $table->index('created_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('notes');
    }
};

Note Model with Polymorphic Relationship

// Source: https://laravel.com/docs/10.x/eloquent-relationships + existing codebase patterns

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Note extends Model
{
    use HasFactory;

    protected $fillable = [
        'notable_type',
        'notable_id',
        'content',
        'author_user_id',
    ];

    /**
     * Get the parent notable model (Member, Issue, etc.)
     */
    public function notable(): MorphTo
    {
        return $this->morphTo();
    }

    /**
     * Get the user who authored this note
     */
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'author_user_id');
    }
}

Member Model with Notes Relationship

// Source: Existing codebase app/Models/Member.php pattern

// Add to app/Models/Member.php

use Illuminate\Database\Eloquent\Relations\MorphMany;

class Member extends Model
{
    // ... existing code ...

    /**
     * Get all notes for this member
     */
    public function notes(): MorphMany
    {
        return $this->morphMany(Note::class, 'notable')->orderBy('created_at', 'desc');
    }
}

Form Request for Note Creation

// Source: Existing codebase app/Http/Requests/StoreMemberRequest.php pattern

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreNoteRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Matches existing admin middleware pattern from app/Http/Middleware/EnsureUserIsAdmin.php
        return $this->user()->hasRole('admin')
            || !$this->user()->getAllPermissions()->isEmpty();
    }

    public function rules(): array
    {
        return [
            'content' => 'required|string|min:1|max:65535',  // longText column limit
        ];
    }

    public function messages(): array
    {
        return [
            'content.required' => '備忘錄內容為必填欄位',
            'content.min' => '備忘錄內容不可為空白',
        ];
    }
}

Admin Controller for Member Notes

// Source: Existing codebase app/Http/Controllers/Admin/ArticleController.php pattern

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreNoteRequest;
use App\Models\Member;
use App\Models\Note;
use App\Support\AuditLogger;
use Illuminate\Support\Facades\DB;

class MemberNoteController extends Controller
{
    public function store(StoreNoteRequest $request, Member $member)
    {
        // Database transaction ensures both note creation and audit logging succeed together
        $note = DB::transaction(function () use ($request, $member) {
            $note = $member->notes()->create([
                'content' => $request->content,
                'author_user_id' => $request->user()->id,
            ]);

            AuditLogger::log('note.created', $note, [
                'member_id' => $member->id,
                'member_name' => $member->full_name,
                'author' => $request->user()->name,
            ]);

            return $note;
        });

        return redirect()
            ->route('admin.members.show', $member)
            ->with('status', '備忘錄已新增');
    }

    public function destroy(Member $member, Note $note)
    {
        // Verify note belongs to this member (prevent URL manipulation)
        if ($note->notable_id !== $member->id || $note->notable_type !== Member::class) {
            abort(404);
        }

        DB::transaction(function () use ($note, $member) {
            $note->delete();

            AuditLogger::log('note.deleted', $member, [
                'note_id' => $note->id,
                'content_preview' => substr($note->content, 0, 50),
                'deleted_by' => auth()->user()->name,
            ]);
        });

        return redirect()
            ->route('admin.members.show', $member)
            ->with('status', '備忘錄已刪除');
    }
}

Preventing N+1 in Member List

// Source: Existing codebase app/Http/Controllers/Admin/ArticleCategoryController.php pattern

public function index(Request $request)
{
    // withCount adds notes_count via single subquery, preventing N+1
    $members = Member::withCount('notes')
        ->orderBy('membership_status')
        ->orderBy('created_at', 'desc')
        ->paginate(20);

    return view('admin.members.index', compact('members'));
}

Blade View Showing Note Count

<!-- Source: Existing codebase pattern -->

@foreach($members as $member)
<tr>
    <td>{{ $member->full_name }}</td>
    <td>{{ $member->membership_status_label }}</td>
    <td>{{ $member->notes_count }}</td>  {{-- No additional query fired --}}
    <td>
        <a href="{{ route('admin.members.show', $member) }}">查看</a>
    </td>
</tr>
@endforeach

Factory for Testing

// Source: Existing codebase database/factories/MemberFactory.php pattern

<?php

namespace Database\Factories;

use App\Models\Note;
use App\Models\Member;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class NoteFactory extends Factory
{
    protected $model = Note::class;

    public function definition(): array
    {
        return [
            'notable_type' => Member::class,
            'notable_id' => Member::factory(),
            'content' => $this->faker->paragraph(),
            'author_user_id' => User::factory(),
        ];
    }

    /**
     * Indicate that the note belongs to a specific member
     */
    public function forMember(Member $member): static
    {
        return $this->state(fn () => [
            'notable_type' => Member::class,
            'notable_id' => $member->id,
        ]);
    }
}

Feature Test Pattern

// Source: Existing codebase tests/Feature/MemberRegistrationTest.php pattern

<?php

namespace Tests\Feature\Admin;

use App\Models\Member;
use App\Models\Note;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class MemberNoteTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->artisan('db:seed', ['--class' => 'RoleSeeder']);
    }

    public function test_admin_can_create_note_for_member(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $member = Member::factory()->create();

        $response = $this->actingAs($admin)
            ->post(route('admin.members.notes.store', $member), [
                'content' => 'Test note content',
            ]);

        $response->assertRedirect(route('admin.members.show', $member));

        $this->assertDatabaseHas('notes', [
            'notable_type' => Member::class,
            'notable_id' => $member->id,
            'content' => 'Test note content',
            'author_user_id' => $admin->id,
        ]);

        // Verify audit log created
        $this->assertDatabaseHas('audit_logs', [
            'action' => 'note.created',
            'auditable_type' => Note::class,
        ]);
    }

    public function test_note_count_is_loaded_without_n_plus_1(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $members = Member::factory()->count(3)->create();
        foreach ($members as $member) {
            Note::factory()->count(2)->forMember($member)->create();
        }

        // Track queries
        DB::enableQueryLog();

        $this->actingAs($admin)
            ->get(route('admin.members.index'));

        $queries = DB::getQueryLog();

        // Should be ~2 queries: 1 for members with count subquery, 1 for pagination count
        $this->assertLessThan(5, count($queries), 'N+1 query detected');
    }
}

State of the Art

Old Approach Current Approach When Changed Impact
Separate notes tables per entity Single polymorphic notes table Laravel 5.0+ (2015) Reduced schema complexity, easier to add new notable types
Manual composite indexes $table->morphs('notable') helper Laravel 5.0+ Automatic index creation, less error-prone
Multiple queries for counts withCount() method Laravel 5.3 (2016) Single subquery for all counts, eliminates N+1
Inline controller validation Form Request classes Laravel 5.0+ Cleaner controllers, reusable validation logic
Manual permission checks Spatie Laravel Permission package Package v5+ Standardized RBAC, role inheritance, caching

Deprecated/outdated:

  • Separate foreign keys per entity type: Use polymorphic notable_type/notable_id instead
  • $table->integer('notable_id')->unsigned(): Use $table->unsignedBigInteger() or let morphs() handle it (auto uses bigIncrements)
  • Manual audit logging to text files: Use structured database logging with AuditLogger::log() for searchability

Open Questions

  1. Should notes have soft deletes?

    • What we know: Existing models like Article use softDeletes(), but Member model does not
    • What's unclear: Business requirement for note recovery vs. hard deletion
    • Recommendation: Start without soft deletes (simpler), add if business requires note recovery. Can be added later via migration without data loss.
  2. Should notes support file attachments?

    • What we know: Requirements mention "text notes" only, existing Article model has separate article_attachments table
    • What's unclear: Future extensibility vs. YAGNI principle
    • Recommendation: Implement text-only per requirements. If attachments needed later, follow Article pattern with polymorphic note_attachments table.
  3. Should notes have access levels (who can view)?

    • What we know: Article model has access_level (public/members/board/admin), requirements specify "all admin roles can view and write"
    • What's unclear: Whether different admin roles should have read/write separation
    • Recommendation: Start with uniform admin access (simpler, matches requirements). If granular permissions needed, add viewNotes and writeNotes permissions via Spatie package.
  4. Should notable_type use morph map or full class names?

    • What we know: Morph map decouples database from class names, but adds configuration overhead
    • What's unclear: Likelihood of model namespace refactoring in this codebase
    • Recommendation: Use morph map defensively - add Relation::enforceMorphMap(['member' => Member::class]) in AppServiceProvider::boot(). Low cost, high protection against future refactoring pain.

Sources

Primary (HIGH confidence)

  • Laravel 10.x Eloquent Relationships - Polymorphic - Official documentation for morphMany/morphTo patterns
  • Laravel 10.x Form Request Validation - Official documentation for Form Request classes
  • Existing codebase patterns:
    • app/Models/Member.php - Model structure with constants, relationships
    • app/Models/CustomFieldValue.php - Polymorphic relationship example
    • app/Http/Requests/StoreMemberRequest.php - Form Request validation pattern
    • app/Http/Controllers/Admin/ArticleController.php - Admin controller return patterns
    • app/Support/AuditLogger.php - Audit logging implementation
    • database/migrations/2026_02_07_120002_create_articles_table.php - Modern migration structure

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Laravel 10.50.0 confirmed via php artisan --version, existing patterns verified in codebase
  • Architecture: HIGH - Polymorphic relationships, Form Requests, and admin patterns all verified in existing codebase
  • Pitfalls: HIGH - Composite indexing and N+1 prevention verified in Laravel 10 docs and performance articles from authoritative sources
  • Code examples: HIGH - All examples derived from official Laravel 10 docs or existing codebase patterns

Research date: 2026-02-13 Valid until: 2026-03-15 (30 days - Laravel 10 is stable, patterns unlikely to change)