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
Recommended Project Structure
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()orwith()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_idinstead $table->integer('notable_id')->unsigned(): Use$table->unsignedBigInteger()or letmorphs()handle it (auto uses bigIncrements)- Manual audit logging to text files: Use structured database logging with
AuditLogger::log()for searchability
Open Questions
-
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.
- What we know: Existing models like Article use
-
Should notes support file attachments?
- What we know: Requirements mention "text notes" only, existing Article model has separate
article_attachmentstable - What's unclear: Future extensibility vs. YAGNI principle
- Recommendation: Implement text-only per requirements. If attachments needed later, follow Article pattern with polymorphic
note_attachmentstable.
- What we know: Requirements mention "text notes" only, existing Article model has separate
-
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
viewNotesandwriteNotespermissions via Spatie package.
- What we know: Article model has
-
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])inAppServiceProvider::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, relationshipsapp/Models/CustomFieldValue.php- Polymorphic relationship exampleapp/Http/Requests/StoreMemberRequest.php- Form Request validation patternapp/Http/Controllers/Admin/ArticleController.php- Admin controller return patternsapp/Support/AuditLogger.php- Audit logging implementationdatabase/migrations/2026_02_07_120002_create_articles_table.php- Modern migration structure
Secondary (MEDIUM confidence)
- Roelof Jan Elsinga - Improve query performance for polymorphic relationships - Composite index performance validation
- Yellow Duck - Laravel's Eloquent withCount method - N+1 prevention with withCount
- René Roth - Composite indexes in Laravel & MySQL - Index column ordering best practices
- Laravel Daily - Database Transactions: 3 Practical Examples - Transaction usage patterns
- Gergő Tar - Handling API Controllers and JSON Responses - API response patterns (verified admin uses Blade, not JSON)
Tertiary (LOW confidence)
- LogRocket - Polymorphic relationships in Laravel and their use cases - General conceptual overview
- LinkedIn - Implementing and Seeding Polymorphic Relationships - Factory patterns for testing
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)