13 KiB
Architecture Patterns
Domain: CRM/Admin Member Note Systems Researched: 2026-02-13
Recommended Architecture
Pattern: Polymorphic Note System with Inline AJAX UI
┌─────────────────────────────────────────────────────────────────┐
│ Member List Page (Blade) │
│ /admin/members │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Member Row (Alpine.js Component) │ │
│ │ │ │
│ │ Name │ Status │ [Note Badge: 3] │ Actions │ │
│ │ ↓ (click to expand) │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Expandable Note Panel (x-show) │ │ │
│ │ │ │ │ │
│ │ │ [Quick Add Form] │ │ │
│ │ │ Textarea: "新增備註..." │ │ │
│ │ │ [儲存] [取消] │ │ │
│ │ │ │ │ │
│ │ │ Note History (chronological, most recent first): │ │ │
│ │ │ • 2026-02-13 14:30 - Admin Name │ │ │
│ │ │ "Contacted about membership renewal..." │ │ │
│ │ │ • 2026-02-11 09:15 - Secretary Name │ │ │
│ │ │ "Follow-up needed on payment issue" │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↕ AJAX (Axios)
┌─────────────────────────────────────────────────────────────────┐
│ Laravel Backend API │
│ │
│ MemberNoteController │
│ ├─ index(Member $member): GET /admin/members/{id}/notes │
│ │ → Returns NoteResource::collection($member->notes) │
│ └─ store(Member $member, Request): POST /admin/members/{id}/notes│
│ → Validates, creates Note, logs audit, returns NoteResource │
└─────────────────────────────────────────────────────────────────┘
↕ Eloquent ORM
┌─────────────────────────────────────────────────────────────────┐
│ Database Layer │
│ │
│ members table notes table (polymorphic) │
│ ├─ id ├─ id │
│ ├─ name ├─ notable_id ──┐ │
│ ├─ ... ├─ notable_type ──┼─> Polymorphic │
│ ├─ content │ Relationship │
│ ├─ user_id (author)│ │
│ ├─ created_at │ │
│ └─ updated_at ────┘ │
└─────────────────────────────────────────────────────────────────┘
Component Boundaries
| Component | Responsibility | Communicates With |
|---|---|---|
| Member List Page (Blade) | Renders member table, embeds Alpine.js components per row | Alpine.js components |
| Alpine.js Note Component | Manages note UI state (expanded, loading, form data), handles AJAX calls | MemberNoteController via Axios |
| MemberNoteController | Validates requests, orchestrates note CRUD, returns JSON | Note model, AuditLogger |
| Note Model (Eloquent) | Manages polymorphic relationship, defines fillable fields, relationships | members table, users table (author) |
| NoteResource | Transforms Note model to consistent JSON structure | MemberNoteController |
| AuditLogger | Records note creation events for compliance | Audit logs table |
Data Flow
Creating a Note (Inline):
- User types note in textarea, clicks "儲存"
- Alpine.js component calls
addNote()method - Axios sends POST
/admin/members/{id}/noteswith CSRF token - MemberNoteController validates request (required content, max length)
- Controller creates Note record with
notable_type='App\Models\Member',notable_id={id},user_id=auth()->id() - AuditLogger logs event:
note_createdwith member ID, author ID, note ID - Controller returns
{ data: NoteResource($note) }(201 Created) - Alpine.js component prepends new note to
this.notesarray - UI updates instantly (no page reload), badge count increments
Loading Note History (Expandable):
- User clicks note badge (count)
- Alpine.js component triggers
x-showtoggle (if first click, already loaded viax-init) - If not lazy-loaded: Axios sends GET
/admin/members/{id}/notes - MemberNoteController queries
$member->notes()->with('author')->get() - Controller returns
{ data: NoteResource::collection($notes) } - Alpine.js component sets
this.notes = response.data.data - UI renders note list with author names, timestamps (relative + tooltip absolute)
Patterns to Follow
Pattern 1: Polymorphic Relationship for Extensibility
What: Use Laravel's polymorphic morphMany/morphTo to attach notes to any entity.
When: Notes can eventually apply to Members, Issues, Payments, Approvals, etc.
Example: ```php // Note model public function notable(): MorphTo { return $this->morphTo(); }
// Member model public function notes(): MorphMany { return $this->morphMany(Note::class, 'notable')->latest(); }
// Later: Issue model can add same relationship without schema changes public function notes(): MorphMany { return $this->morphMany(Note::class, 'notable')->latest(); } ```
Benefits:
- Single notes table for all entities (DRY)
- Zero schema changes to add notes to new entities
- Consistent query API:
$member->notes,$issue->notes
Pattern 2: Eager Loading with Count Aggregation
What: Load note counts on member list without N+1 queries.
When: Displaying note count badges on member list page.
Example: ```php // In MemberController@index $members = Member::query() ->withCount('notes') // Adds 'notes_count' attribute ->paginate(15);
// In Blade template @if ($member->notes_count > 0) {{ $member->notes_count }} @endif ```
Benefits:
- Single query for all note counts (vs. N queries for N members)
- No caching needed for counts (eager load is fast)
- Automatic when using Eloquent relationships
Pattern 3: Alpine.js Component State Isolation
What: Each member row has isolated Alpine.js state (not global).
When: Multiple member rows on page, each with expandable notes.
Example: ```html
{{ $member->name }} {{ $member->notes_count }} \`\`\`Benefits:
- No state collision between rows
- Each row manages own loading/expanded state
- No global store needed (Alpine.js magic)
Pattern 4: Optimistic UI Updates
What: Update UI immediately after POST, before server confirms success.
When: Creating new note (AJAX).
Example: ```javascript async addNote() { const tempNote = { id: Date.now(), // Temporary ID content: this.newNote, author: { name: '{{ auth()->user()->name }}' }, created_at: new Date().toISOString() };
this.notes.unshift(tempNote); // Instant UI update
this.newNote = '';
try {
const response = await axios.post('/admin/members/1/notes', {
content: tempNote.content
});
// Replace temp note with server-confirmed note
this.notes[0] = response.data.data;
} catch (error) {
// Revert on error
this.notes.shift();
alert('新增失敗');
}
} ```
Benefits:
- Instant feedback (no spinner needed)
- Perceived performance improvement
- Graceful degradation on error
Anti-Patterns to Avoid
Anti-Pattern 1: Global Alpine.js Store for Notes
What: Using Alpine.store() to share notes across components.
Why bad:
- State mutations in one row affect other rows
- Memory leak if notes accumulate in global store
- Harder to debug (implicit dependencies)
Instead: Component-scoped state with x-data per row (Pattern 3).
Anti-Pattern 2: Soft Deletes on Notes
What: Adding deleted_at column for "hiding" notes.
Why bad:
- Violates append-only audit principle
- "Deleted" notes still in database (confusion)
- Soft-deleted records pollute queries (need
withTrashed()everywhere)
Instead: Hard constraint: no delete operation at all. Use addendum pattern for corrections.
Anti-Pattern 3: Lazy Loading Notes on Member List
What: Loading $member->notes in Blade loop without with().
Why bad:
- N+1 query problem (1 query per member)
- Slow page load with 15 members = 15+ queries
- Database connection pool exhaustion
Instead: Eager load with withCount('notes') or with('notes') in controller query (Pattern 2).
Anti-Pattern 4: Full Page Reload After Note Creation
What: Traditional form submit that redirects back to member list.
Why bad:
- Loses scroll position
- Slower (re-renders entire table)
- Poor UX for quick note-taking
Instead: AJAX with Alpine.js, update only the note panel (Pattern 4).
Scalability Considerations
| Concern | At 100 notes total | At 1,000 notes total | At 10,000 notes total |
|---|---|---|---|
| Query performance | No optimization needed | Index on notable_id + created_at (already planned) |
Same indexes sufficient; consider pagination per member if >100 notes/member |
| Badge count | withCount('notes') is fast |
Same (single GROUP BY query) | Same (scales to 100K+ members) |
| Note history load | Load all notes on expand | Load all notes on expand | Paginate if member has >50 notes (unlikely in NPO) |
| Search | LIKE query on content |
MySQL FULLTEXT index | Same (FULLTEXT scales to 100K+ notes) |
| Storage | ~10KB total (100 notes × 100 chars) | ~100KB total | ~1MB total (negligible) |
NPO Context: With ~200 members, unlikely to exceed 1,000 total notes even with active chairman usage. Current architecture scales 100x beyond realistic usage.
Sources
- Laravel 10.x Polymorphic Relationships - Official documentation
- Eloquent N+1 Query Detection - Performance optimization
- Alpine.js Component Patterns - State management
- RESTful API Design Best Practices - AJAX endpoint patterns
- Healthcare Audit Trail Standards - Append-only pattern
- MySQL FULLTEXT Search - Search optimization
Architecture research for: Member Notes System (會員備註系統) Researched: 2026-02-13