feat(docs): update TODO list with completed documentation tasks and add new guides for service contracts, seeder system, and SQL security
This commit is contained in:
parent
62c23b7fe9
commit
7631afb12e
17 changed files with 8708 additions and 106 deletions
43
TODO.md
43
TODO.md
|
|
@ -1,43 +1,14 @@
|
||||||
# Core PHP Framework - TODO
|
# Core PHP Framework - TODO
|
||||||
|
|
||||||
No pending tasks! 🎉
|
No pending tasks.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Completed (January 2026)
|
|
||||||
|
|
||||||
### Security Fixes
|
|
||||||
|
|
||||||
- [x] **MCP: Database Connection Fallback** - Fixed to throw exception instead of silently falling back to default connection
|
|
||||||
- See: `packages/core-mcp/changelog/2026/jan/security.md`
|
|
||||||
|
|
||||||
- [x] **MCP: SQL Validator Regex** - Strengthened WHERE clause patterns to prevent SQL injection vectors
|
|
||||||
- See: `packages/core-mcp/changelog/2026/jan/security.md`
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- [x] **MCP: EXPLAIN Plan** - Added query optimization analysis with human-readable performance insights
|
|
||||||
- See: `packages/core-mcp/changelog/2026/jan/features.md`
|
|
||||||
|
|
||||||
- [x] **CDN: Integration Tests** - Comprehensive test suite for CDN operations and asset pipeline
|
|
||||||
- See: `packages/core-php/changelog/2026/jan/features.md`
|
|
||||||
|
|
||||||
### Documentation & Code Quality
|
|
||||||
|
|
||||||
- [x] **API docs** - Genericized vendor-specific content (removed Host UK branding, lt.hn references)
|
|
||||||
- See: `packages/core-api/changelog/2026/jan/features.md`
|
|
||||||
|
|
||||||
- [x] **Admin: Route Audit** - Verified admin routes use Livewire modals instead of traditional controllers; #[Action] attributes not applicable
|
|
||||||
|
|
||||||
- [x] **ServicesAdmin** - Reviewed stubbed bio service methods; intentionally stubbed pending module extraction (documented with TODO comments)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Package Changelogs
|
## Package Changelogs
|
||||||
|
|
||||||
For complete feature lists and implementation details:
|
For completed features and implementation details, see each package's changelog:
|
||||||
- `packages/core-php/changelog/2026/jan/features.md`
|
|
||||||
- `packages/core-admin/changelog/2026/jan/features.md`
|
- `packages/core-php/changelog/`
|
||||||
- `packages/core-api/changelog/2026/jan/features.md`
|
- `packages/core-admin/changelog/`
|
||||||
- `packages/core-mcp/changelog/2026/jan/features.md`
|
- `packages/core-api/changelog/`
|
||||||
- `packages/core-mcp/changelog/2026/jan/security.md` ⚠️ Security fixes
|
- `packages/core-mcp/changelog/`
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,9 @@ export default defineConfig({
|
||||||
{ text: 'Activity Logging', link: '/packages/core/activity' },
|
{ text: 'Activity Logging', link: '/packages/core/activity' },
|
||||||
{ text: 'Media Processing', link: '/packages/core/media' },
|
{ text: 'Media Processing', link: '/packages/core/media' },
|
||||||
{ text: 'Search', link: '/packages/core/search' },
|
{ text: 'Search', link: '/packages/core/search' },
|
||||||
{ text: 'SEO Tools', link: '/packages/core/seo' }
|
{ text: 'SEO Tools', link: '/packages/core/seo' },
|
||||||
|
{ text: 'Service Contracts', link: '/packages/core/service-contracts' },
|
||||||
|
{ text: 'Seeder System', link: '/packages/core/seeder-system' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -117,7 +119,10 @@ export default defineConfig({
|
||||||
{ text: 'Global Search', link: '/packages/admin/search' },
|
{ text: 'Global Search', link: '/packages/admin/search' },
|
||||||
{ text: 'Admin Menus', link: '/packages/admin/menus' },
|
{ text: 'Admin Menus', link: '/packages/admin/menus' },
|
||||||
{ text: 'Authorization', link: '/packages/admin/authorization' },
|
{ text: 'Authorization', link: '/packages/admin/authorization' },
|
||||||
{ text: 'UI Components', link: '/packages/admin/components' }
|
{ text: 'UI Components', link: '/packages/admin/components' },
|
||||||
|
{ text: 'Creating Admin Panels', link: '/packages/admin/creating-admin-panels' },
|
||||||
|
{ text: 'HLCRF Deep Dive', link: '/packages/admin/hlcrf-deep-dive' },
|
||||||
|
{ text: 'Components Reference', link: '/packages/admin/components-reference' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -131,7 +136,10 @@ export default defineConfig({
|
||||||
{ text: 'Webhooks', link: '/packages/api/webhooks' },
|
{ text: 'Webhooks', link: '/packages/api/webhooks' },
|
||||||
{ text: 'Rate Limiting', link: '/packages/api/rate-limiting' },
|
{ text: 'Rate Limiting', link: '/packages/api/rate-limiting' },
|
||||||
{ text: 'Scopes', link: '/packages/api/scopes' },
|
{ text: 'Scopes', link: '/packages/api/scopes' },
|
||||||
{ text: 'Documentation', link: '/packages/api/documentation' }
|
{ text: 'Documentation', link: '/packages/api/documentation' },
|
||||||
|
{ text: 'Building REST APIs', link: '/packages/api/building-rest-apis' },
|
||||||
|
{ text: 'Webhook Integration', link: '/packages/api/webhook-integration' },
|
||||||
|
{ text: 'Endpoints Reference', link: '/packages/api/endpoints-reference' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -146,7 +154,10 @@ export default defineConfig({
|
||||||
{ text: 'Security', link: '/packages/mcp/security' },
|
{ text: 'Security', link: '/packages/mcp/security' },
|
||||||
{ text: 'Workspace Context', link: '/packages/mcp/workspace' },
|
{ text: 'Workspace Context', link: '/packages/mcp/workspace' },
|
||||||
{ text: 'Analytics', link: '/packages/mcp/analytics' },
|
{ text: 'Analytics', link: '/packages/mcp/analytics' },
|
||||||
{ text: 'Usage Quotas', link: '/packages/mcp/quotas' }
|
{ text: 'Usage Quotas', link: '/packages/mcp/quotas' },
|
||||||
|
{ text: 'Creating MCP Tools', link: '/packages/mcp/creating-mcp-tools' },
|
||||||
|
{ text: 'SQL Security', link: '/packages/mcp/sql-security' },
|
||||||
|
{ text: 'Tools Reference', link: '/packages/mcp/tools-reference' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
784
docs/packages/admin/components-reference.md
Normal file
784
docs/packages/admin/components-reference.md
Normal file
|
|
@ -0,0 +1,784 @@
|
||||||
|
# Components Reference
|
||||||
|
|
||||||
|
Complete API reference for all form components in the Admin package, including prop documentation, validation rules, authorization integration, and accessibility notes.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
All form components in Core PHP:
|
||||||
|
- Wrap Flux UI components with additional features
|
||||||
|
- Support authorization via `canGate` and `canResource` props
|
||||||
|
- Include ARIA accessibility attributes
|
||||||
|
- Work seamlessly with Livewire
|
||||||
|
- Follow consistent naming conventions
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
Text input with various types and authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="Enter title"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier for the input |
|
||||||
|
| `label` | string | `null` | Label text displayed above input |
|
||||||
|
| `helper` | string | `null` | Helper text displayed below input |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource to check ability against |
|
||||||
|
| `instantSave` | bool | `false` | Use `wire:model.live.debounce.500ms` |
|
||||||
|
| `type` | string | `'text'` | Input type (text, email, password, number, etc.) |
|
||||||
|
| `placeholder` | string | `null` | Placeholder text |
|
||||||
|
| `disabled` | bool | `false` | Disable the input |
|
||||||
|
| `readonly` | bool | `false` | Make input read-only |
|
||||||
|
| `required` | bool | `false` | Mark as required |
|
||||||
|
| `min` | number | `null` | Minimum value (for number inputs) |
|
||||||
|
| `max` | number | `null` | Maximum value (for number inputs) |
|
||||||
|
| `maxlength` | number | `null` | Maximum character length |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Input disabled if user cannot update the post --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Variants
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Text input --}}
|
||||||
|
<x-forms.input id="name" label="Name" type="text" />
|
||||||
|
|
||||||
|
{{-- Email input --}}
|
||||||
|
<x-forms.input id="email" label="Email" type="email" />
|
||||||
|
|
||||||
|
{{-- Password input --}}
|
||||||
|
<x-forms.input id="password" label="Password" type="password" />
|
||||||
|
|
||||||
|
{{-- Number input --}}
|
||||||
|
<x-forms.input id="quantity" label="Quantity" type="number" min="1" max="100" />
|
||||||
|
|
||||||
|
{{-- Date input --}}
|
||||||
|
<x-forms.input id="date" label="Date" type="date" />
|
||||||
|
|
||||||
|
{{-- URL input --}}
|
||||||
|
<x-forms.input id="website" label="Website" type="url" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instant Save Mode
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Saves with 500ms debounce --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="slug"
|
||||||
|
wire:model="slug"
|
||||||
|
label="Slug"
|
||||||
|
instantSave
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
The component automatically:
|
||||||
|
- Associates label with input via `id`
|
||||||
|
- Links error messages with `aria-describedby`
|
||||||
|
- Sets `aria-invalid="true"` when validation fails
|
||||||
|
- Includes helper text in accessible description
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Textarea
|
||||||
|
|
||||||
|
Multi-line text input with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
wire:model="content"
|
||||||
|
label="Content"
|
||||||
|
rows="10"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text |
|
||||||
|
| `helper` | string | `null` | Helper text |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live debounced binding |
|
||||||
|
| `rows` | number | `3` | Number of visible rows |
|
||||||
|
| `placeholder` | string | `null` | Placeholder text |
|
||||||
|
| `disabled` | bool | `false` | Disable the textarea |
|
||||||
|
| `maxlength` | number | `null` | Maximum character length |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.textarea
|
||||||
|
id="bio"
|
||||||
|
wire:model="bio"
|
||||||
|
label="Biography"
|
||||||
|
rows="5"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$profile"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Character Limit
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.textarea
|
||||||
|
id="description"
|
||||||
|
wire:model="description"
|
||||||
|
label="Description"
|
||||||
|
maxlength="500"
|
||||||
|
helper="Maximum 500 characters"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select
|
||||||
|
|
||||||
|
Dropdown select with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
wire:model="status"
|
||||||
|
label="Status"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
<flux:select.option value="archived">Archived</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text |
|
||||||
|
| `helper` | string | `null` | Helper text |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live binding |
|
||||||
|
| `placeholder` | string | `null` | Placeholder option text |
|
||||||
|
| `disabled` | bool | `false` | Disable the select |
|
||||||
|
| `multiple` | bool | `false` | Allow multiple selections |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="category"
|
||||||
|
wire:model="category_id"
|
||||||
|
label="Category"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
placeholder="Select a category..."
|
||||||
|
>
|
||||||
|
@foreach($categories as $category)
|
||||||
|
<flux:select.option value="{{ $category->id }}">
|
||||||
|
{{ $category->name }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Placeholder
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="country"
|
||||||
|
wire:model="country"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Choose a country..."
|
||||||
|
>
|
||||||
|
<flux:select.option value="us">United States</flux:select.option>
|
||||||
|
<flux:select.option value="uk">United Kingdom</flux:select.option>
|
||||||
|
<flux:select.option value="ca">Canada</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Selection
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.select
|
||||||
|
id="tags"
|
||||||
|
wire:model="selectedTags"
|
||||||
|
label="Tags"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
@foreach($tags as $tag)
|
||||||
|
<flux:select.option value="{{ $tag->id }}">
|
||||||
|
{{ $tag->name }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</x-forms.select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkbox
|
||||||
|
|
||||||
|
Single checkbox with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="featured"
|
||||||
|
wire:model="featured"
|
||||||
|
label="Featured Post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text (displayed inline) |
|
||||||
|
| `helper` | string | `null` | Helper text below checkbox |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live binding |
|
||||||
|
| `disabled` | bool | `false` | Disable the checkbox |
|
||||||
|
| `value` | string | `null` | Checkbox value (for arrays) |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="published"
|
||||||
|
wire:model="published"
|
||||||
|
label="Publish immediately"
|
||||||
|
canGate="publish"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Helper Text
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="newsletter"
|
||||||
|
wire:model="newsletter"
|
||||||
|
label="Subscribe to newsletter"
|
||||||
|
helper="Receive weekly updates about new features"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkbox Group
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<fieldset>
|
||||||
|
<legend class="font-medium mb-2">Notifications</legend>
|
||||||
|
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_email"
|
||||||
|
wire:model="notifications"
|
||||||
|
label="Email notifications"
|
||||||
|
value="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_sms"
|
||||||
|
wire:model="notifications"
|
||||||
|
label="SMS notifications"
|
||||||
|
value="sms"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_push"
|
||||||
|
wire:model="notifications"
|
||||||
|
label="Push notifications"
|
||||||
|
value="push"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toggle
|
||||||
|
|
||||||
|
Switch-style toggle with authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.toggle
|
||||||
|
id="active"
|
||||||
|
wire:model="active"
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `id` | string | **required** | Unique identifier |
|
||||||
|
| `label` | string | `null` | Label text (displayed to the left) |
|
||||||
|
| `helper` | string | `null` | Helper text below toggle |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `instantSave` | bool | `false` | Use live binding |
|
||||||
|
| `disabled` | bool | `false` | Disable the toggle |
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.toggle
|
||||||
|
id="is_admin"
|
||||||
|
wire:model="is_admin"
|
||||||
|
label="Administrator"
|
||||||
|
canGate="manageRoles"
|
||||||
|
:canResource="$user"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instant Save
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Toggle that saves immediately --}}
|
||||||
|
<x-forms.toggle
|
||||||
|
id="notifications_enabled"
|
||||||
|
wire:model="notifications_enabled"
|
||||||
|
label="Enable Notifications"
|
||||||
|
instantSave
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Helper
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.toggle
|
||||||
|
id="two_factor"
|
||||||
|
wire:model="two_factor_enabled"
|
||||||
|
label="Two-Factor Authentication"
|
||||||
|
helper="Add an extra layer of security to your account"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button
|
||||||
|
|
||||||
|
Action button with variants and authorization support.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.button type="submit">
|
||||||
|
Save Changes
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `variant` | string | `'primary'` | Button style variant |
|
||||||
|
| `type` | string | `'submit'` | Button type (submit, button, reset) |
|
||||||
|
| `canGate` | string | `null` | Gate/policy ability to check |
|
||||||
|
| `canResource` | mixed | `null` | Resource for ability check |
|
||||||
|
| `disabled` | bool | `false` | Disable the button |
|
||||||
|
| `loading` | bool | `false` | Show loading state |
|
||||||
|
|
||||||
|
### Variants
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Primary (default) --}}
|
||||||
|
<x-forms.button variant="primary">Primary</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Secondary --}}
|
||||||
|
<x-forms.button variant="secondary">Secondary</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Danger --}}
|
||||||
|
<x-forms.button variant="danger">Delete</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Ghost --}}
|
||||||
|
<x-forms.button variant="ghost">Cancel</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Button disabled if user cannot delete --}}
|
||||||
|
<x-forms.button
|
||||||
|
variant="danger"
|
||||||
|
canGate="delete"
|
||||||
|
:canResource="$post"
|
||||||
|
wire:click="delete"
|
||||||
|
>
|
||||||
|
Delete Post
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Loading State
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.button type="submit" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.remove>Save</span>
|
||||||
|
<span wire:loading>Saving...</span>
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### As Link
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.href='{{ route('admin.posts') }}'"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authorization Props Reference
|
||||||
|
|
||||||
|
All form components support authorization through consistent props.
|
||||||
|
|
||||||
|
### How Authorization Works
|
||||||
|
|
||||||
|
When `canGate` and `canResource` are provided, the component checks if the authenticated user can perform the specified ability on the resource:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Equivalent PHP check
|
||||||
|
auth()->user()?->can($canGate, $canResource)
|
||||||
|
```
|
||||||
|
|
||||||
|
If the check fails, the component is **disabled** (not hidden).
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `canGate` | string | The ability/gate name to check (e.g., `'update'`, `'delete'`, `'publish'`) |
|
||||||
|
| `canResource` | mixed | The resource to check the ability against (usually a model instance) |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Basic Policy Check:**
|
||||||
|
```blade
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple Components with Same Auth:**
|
||||||
|
```blade
|
||||||
|
@php $canEdit = auth()->user()?->can('update', $post); @endphp
|
||||||
|
|
||||||
|
<x-forms.input id="title" wire:model="title" :disabled="!$canEdit" />
|
||||||
|
<x-forms.textarea id="content" wire:model="content" :disabled="!$canEdit" />
|
||||||
|
<x-forms.button type="submit" :disabled="!$canEdit">Save</x-forms.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Combining with Blade Directives:**
|
||||||
|
```blade
|
||||||
|
@can('update', $post)
|
||||||
|
<x-forms.input id="title" wire:model="title" />
|
||||||
|
<x-forms.button type="submit">Save</x-forms.button>
|
||||||
|
@else
|
||||||
|
<p>You do not have permission to edit this post.</p>
|
||||||
|
@endcan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Defining Policies
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
public function update(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->id === $post->author_id
|
||||||
|
|| $user->hasRole('editor');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.publish')
|
||||||
|
&& $post->status === 'draft';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility Notes
|
||||||
|
|
||||||
|
### ARIA Attributes
|
||||||
|
|
||||||
|
All components automatically include appropriate ARIA attributes:
|
||||||
|
|
||||||
|
| Attribute | Usage |
|
||||||
|
|-----------|-------|
|
||||||
|
| `aria-labelledby` | Links to label element |
|
||||||
|
| `aria-describedby` | Links to helper text and error messages |
|
||||||
|
| `aria-invalid` | Set to `true` when validation fails |
|
||||||
|
| `aria-required` | Set when field is required |
|
||||||
|
| `aria-disabled` | Set when field is disabled |
|
||||||
|
|
||||||
|
### Label Association
|
||||||
|
|
||||||
|
Labels are automatically associated with inputs via the `id` prop:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-forms.input id="email" label="Email Address" />
|
||||||
|
|
||||||
|
{{-- Renders as: --}}
|
||||||
|
<flux:field>
|
||||||
|
<flux:label for="email">Email Address</flux:label>
|
||||||
|
<flux:input id="email" name="email" />
|
||||||
|
</flux:field>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Announcements
|
||||||
|
|
||||||
|
Validation errors are linked to inputs and announced to screen readers:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Component renders error with aria-describedby link --}}
|
||||||
|
<flux:error name="email" />
|
||||||
|
|
||||||
|
{{-- Screen readers announce: "Email is required" --}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Focus Management
|
||||||
|
|
||||||
|
- Tab order follows visual order
|
||||||
|
- Focus states are clearly visible
|
||||||
|
- Error focus moves to first invalid field
|
||||||
|
|
||||||
|
### Keyboard Support
|
||||||
|
|
||||||
|
| Component | Keyboard Support |
|
||||||
|
|-----------|------------------|
|
||||||
|
| Input | Standard text input |
|
||||||
|
| Textarea | Standard multiline |
|
||||||
|
| Select | Arrow keys, Enter, Escape |
|
||||||
|
| Checkbox | Space to toggle |
|
||||||
|
| Toggle | Space to toggle, Arrow keys |
|
||||||
|
| Button | Enter/Space to activate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Integration
|
||||||
|
|
||||||
|
### Server-Side Validation
|
||||||
|
|
||||||
|
Components automatically display Laravel validation errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In Livewire component
|
||||||
|
protected array $rules = [
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'content' => 'required',
|
||||||
|
'status' => 'required|in:draft,published',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
// Errors automatically shown on components
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real-Time Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function updated($propertyName): void
|
||||||
|
{
|
||||||
|
$this->validateOnly($propertyName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Shows validation error as user types --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="email"
|
||||||
|
wire:model.live="email"
|
||||||
|
label="Email"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Error Messages
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected array $messages = [
|
||||||
|
'title.required' => 'Please enter a post title.',
|
||||||
|
'content.required' => 'Post content cannot be empty.',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Form Example
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
{{-- Title --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
placeholder="Enter post title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Slug with instant save --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="slug"
|
||||||
|
wire:model="slug"
|
||||||
|
label="Slug"
|
||||||
|
helper="URL-friendly version of the title"
|
||||||
|
instantSave
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Content --}}
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
wire:model="content"
|
||||||
|
label="Content"
|
||||||
|
rows="15"
|
||||||
|
placeholder="Write your content here..."
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Category --}}
|
||||||
|
<x-forms.select
|
||||||
|
id="category_id"
|
||||||
|
wire:model="category_id"
|
||||||
|
label="Category"
|
||||||
|
placeholder="Select a category..."
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
@foreach($categories as $category)
|
||||||
|
<flux:select.option value="{{ $category->id }}">
|
||||||
|
{{ $category->name }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
{{-- Status --}}
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
wire:model="status"
|
||||||
|
label="Status"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
<flux:select.option value="archived">Archived</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
{{-- Featured toggle --}}
|
||||||
|
<x-forms.toggle
|
||||||
|
id="featured"
|
||||||
|
wire:model="featured"
|
||||||
|
label="Featured Post"
|
||||||
|
helper="Display prominently on the homepage"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Newsletter checkbox --}}
|
||||||
|
<x-forms.checkbox
|
||||||
|
id="notify_subscribers"
|
||||||
|
wire:model="notify_subscribers"
|
||||||
|
label="Notify subscribers"
|
||||||
|
helper="Send email notification when published"
|
||||||
|
canGate="publish"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Actions --}}
|
||||||
|
<div class="flex gap-3 pt-4 border-t">
|
||||||
|
<x-forms.button
|
||||||
|
type="submit"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
<x-forms.button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.href='{{ route('admin.posts') }}'"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
@can('delete', $post)
|
||||||
|
<x-forms.button
|
||||||
|
variant="danger"
|
||||||
|
type="button"
|
||||||
|
wire:click="delete"
|
||||||
|
wire:confirm="Are you sure you want to delete this post?"
|
||||||
|
class="ml-auto"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</x-forms.button>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Form Components Guide](/packages/admin/forms)
|
||||||
|
- [Authorization](/packages/admin/authorization)
|
||||||
|
- [Creating Admin Panels](/packages/admin/creating-admin-panels)
|
||||||
|
- [Livewire Modals](/packages/admin/modals)
|
||||||
931
docs/packages/admin/creating-admin-panels.md
Normal file
931
docs/packages/admin/creating-admin-panels.md
Normal file
|
|
@ -0,0 +1,931 @@
|
||||||
|
# Creating Admin Panels
|
||||||
|
|
||||||
|
This guide covers the complete process of creating admin panels in the Core PHP Framework, including menu registration, modal creation, and authorization integration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Admin panels in Core PHP use:
|
||||||
|
- **AdminMenuProvider** - Interface for menu registration
|
||||||
|
- **Livewire Modals** - Full-page components for admin interfaces
|
||||||
|
- **Authorization Props** - Built-in permission checking on components
|
||||||
|
- **HLCRF Layouts** - Composable layout system
|
||||||
|
|
||||||
|
## Menu Registration with AdminMenuProvider
|
||||||
|
|
||||||
|
### Implementing AdminMenuProvider
|
||||||
|
|
||||||
|
The `AdminMenuProvider` interface allows modules to contribute navigation items to the admin sidebar.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Core\Front\Admin\Concerns\HasMenuPermissions;
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
use Core\Front\Admin\AdminMenuRegistry;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class Boot extends ServiceProvider implements AdminMenuProvider
|
||||||
|
{
|
||||||
|
use HasMenuPermissions;
|
||||||
|
|
||||||
|
public static array $listens = [
|
||||||
|
AdminPanelBooting::class => 'onAdminPanel',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
// Register views and routes
|
||||||
|
$event->views('blog', __DIR__.'/View/Blade');
|
||||||
|
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||||
|
|
||||||
|
// Register menu provider
|
||||||
|
app(AdminMenuRegistry::class)->register($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminMenuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Dashboard item in standalone group
|
||||||
|
[
|
||||||
|
'group' => 'dashboard',
|
||||||
|
'priority' => self::PRIORITY_HIGH,
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog Dashboard',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'href' => route('admin.blog.dashboard'),
|
||||||
|
'active' => request()->routeIs('admin.blog.dashboard'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Service item with entitlement
|
||||||
|
[
|
||||||
|
'group' => 'services',
|
||||||
|
'priority' => self::PRIORITY_NORMAL,
|
||||||
|
'entitlement' => 'core.srv.blog',
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'href' => route('admin.blog.posts'),
|
||||||
|
'active' => request()->routeIs('admin.blog.*'),
|
||||||
|
'color' => 'blue',
|
||||||
|
'badge' => Post::draft()->count() ?: null,
|
||||||
|
'children' => [
|
||||||
|
['label' => 'All Posts', 'href' => route('admin.blog.posts'), 'icon' => 'document-text'],
|
||||||
|
['label' => 'Categories', 'href' => route('admin.blog.categories'), 'icon' => 'folder'],
|
||||||
|
['label' => 'Tags', 'href' => route('admin.blog.tags'), 'icon' => 'tag'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Admin-only item
|
||||||
|
[
|
||||||
|
'group' => 'admin',
|
||||||
|
'priority' => self::PRIORITY_LOW,
|
||||||
|
'admin' => true,
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog Settings',
|
||||||
|
'icon' => 'gear',
|
||||||
|
'href' => route('admin.blog.settings'),
|
||||||
|
'active' => request()->routeIs('admin.blog.settings'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu Item Structure
|
||||||
|
|
||||||
|
Each item in `adminMenuItems()` follows this structure:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `group` | string | Menu group: `dashboard`, `workspaces`, `services`, `settings`, `admin` |
|
||||||
|
| `priority` | int | Order within group (use `PRIORITY_*` constants) |
|
||||||
|
| `entitlement` | string | Optional workspace feature code for access |
|
||||||
|
| `permissions` | array | Optional user permission keys required |
|
||||||
|
| `admin` | bool | Requires Hades/admin user |
|
||||||
|
| `item` | Closure | Lazy-evaluated item data |
|
||||||
|
|
||||||
|
### Priority Constants
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
|
||||||
|
// Available priority constants
|
||||||
|
AdminMenuProvider::PRIORITY_FIRST // 0-9: System items
|
||||||
|
AdminMenuProvider::PRIORITY_HIGH // 10-19: Primary navigation
|
||||||
|
AdminMenuProvider::PRIORITY_ABOVE_NORMAL // 20-39: Important items
|
||||||
|
AdminMenuProvider::PRIORITY_NORMAL // 40-60: Standard items (default)
|
||||||
|
AdminMenuProvider::PRIORITY_BELOW_NORMAL // 61-79: Less important
|
||||||
|
AdminMenuProvider::PRIORITY_LOW // 80-89: Rarely used
|
||||||
|
AdminMenuProvider::PRIORITY_LAST // 90-99: End items
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu Groups
|
||||||
|
|
||||||
|
| Group | Description | Rendering |
|
||||||
|
|-------|-------------|-----------|
|
||||||
|
| `dashboard` | Primary entry points | Standalone items |
|
||||||
|
| `workspaces` | Workspace management | Grouped dropdown |
|
||||||
|
| `services` | Application services | Standalone items |
|
||||||
|
| `settings` | User/account settings | Grouped dropdown |
|
||||||
|
| `admin` | Platform administration | Grouped dropdown (Hades only) |
|
||||||
|
|
||||||
|
### Using MenuItemBuilder
|
||||||
|
|
||||||
|
For complex menus, use the fluent `MenuItemBuilder`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Admin\Support\MenuItemBuilder;
|
||||||
|
|
||||||
|
public function adminMenuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
MenuItemBuilder::make('Commerce')
|
||||||
|
->icon('shopping-cart')
|
||||||
|
->route('admin.commerce.dashboard')
|
||||||
|
->inServices()
|
||||||
|
->priority(self::PRIORITY_NORMAL)
|
||||||
|
->entitlement('core.srv.commerce')
|
||||||
|
->color('green')
|
||||||
|
->badge('New', 'green')
|
||||||
|
->activeOnRoute('admin.commerce.*')
|
||||||
|
->children([
|
||||||
|
MenuItemBuilder::child('Products', route('admin.commerce.products'))
|
||||||
|
->icon('cube'),
|
||||||
|
MenuItemBuilder::child('Orders', route('admin.commerce.orders'))
|
||||||
|
->icon('receipt')
|
||||||
|
->badge(fn () => Order::pending()->count()),
|
||||||
|
['separator' => true],
|
||||||
|
MenuItemBuilder::child('Settings', route('admin.commerce.settings'))
|
||||||
|
->icon('gear'),
|
||||||
|
])
|
||||||
|
->build(),
|
||||||
|
|
||||||
|
MenuItemBuilder::make('Analytics')
|
||||||
|
->icon('chart-line')
|
||||||
|
->route('admin.analytics.dashboard')
|
||||||
|
->inServices()
|
||||||
|
->entitlement('core.srv.analytics')
|
||||||
|
->adminOnly() // Requires admin user
|
||||||
|
->build(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Checking
|
||||||
|
|
||||||
|
The `HasMenuPermissions` trait provides default permission handling:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Admin\Concerns\HasMenuPermissions;
|
||||||
|
|
||||||
|
class BlogMenuProvider implements AdminMenuProvider
|
||||||
|
{
|
||||||
|
use HasMenuPermissions;
|
||||||
|
|
||||||
|
// Override for custom global permissions
|
||||||
|
public function menuPermissions(): array
|
||||||
|
{
|
||||||
|
return ['blog.view'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override for custom permission logic
|
||||||
|
public function canViewMenu(?object $user, ?object $workspace): bool
|
||||||
|
{
|
||||||
|
if ($user === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom logic
|
||||||
|
return $user->hasRole('editor') || $user->isHades();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Livewire Modals
|
||||||
|
|
||||||
|
Livewire modals are full-page components that provide seamless admin interfaces.
|
||||||
|
|
||||||
|
### Basic Modal Structure
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
#[Title('Edit Post')]
|
||||||
|
#[Layout('admin::layouts.app')]
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
public ?Post $post = null;
|
||||||
|
public string $title = '';
|
||||||
|
public string $content = '';
|
||||||
|
public string $status = 'draft';
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'content' => 'required|string',
|
||||||
|
'status' => 'required|in:draft,published,archived',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(?Post $post = null): void
|
||||||
|
{
|
||||||
|
$this->post = $post;
|
||||||
|
|
||||||
|
if ($post) {
|
||||||
|
$this->title = $post->title;
|
||||||
|
$this->content = $post->content;
|
||||||
|
$this->status = $post->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate();
|
||||||
|
|
||||||
|
if ($this->post) {
|
||||||
|
$this->post->update($validated);
|
||||||
|
$message = 'Post updated successfully.';
|
||||||
|
} else {
|
||||||
|
Post::create($validated);
|
||||||
|
$message = 'Post created successfully.';
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('success', $message);
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('blog::admin.post-editor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal View with HLCRF
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- resources/views/admin/post-editor.blade.php --}}
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold">
|
||||||
|
{{ $post ? 'Edit Post' : 'Create Post' }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<a href="{{ route('admin.blog.posts') }}" class="btn-ghost">
|
||||||
|
<x-icon name="x" class="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::header>
|
||||||
|
|
||||||
|
<x-hlcrf::content>
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
label="Title"
|
||||||
|
wire:model="title"
|
||||||
|
placeholder="Enter post title"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
label="Content"
|
||||||
|
wire:model="content"
|
||||||
|
rows="15"
|
||||||
|
placeholder="Write your content here..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
label="Status"
|
||||||
|
wire:model="status"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
<flux:select.option value="archived">Archived</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<x-forms.button type="submit">
|
||||||
|
{{ $post ? 'Update' : 'Create' }} Post
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
<x-forms.button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.href='{{ route('admin.blog.posts') }}'"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</x-forms.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::right>
|
||||||
|
<div class="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h3 class="font-medium mb-2">Publishing Tips</h3>
|
||||||
|
<ul class="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>Use descriptive titles</li>
|
||||||
|
<li>Save as draft first</li>
|
||||||
|
<li>Preview before publishing</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal with Authorization
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class PostEditor extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public Post $post;
|
||||||
|
|
||||||
|
public function mount(Post $post): void
|
||||||
|
{
|
||||||
|
// Authorize on mount
|
||||||
|
$this->authorize('update', $post);
|
||||||
|
|
||||||
|
$this->post = $post;
|
||||||
|
// ... load data
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
// Re-authorize on save
|
||||||
|
$this->authorize('update', $this->post);
|
||||||
|
|
||||||
|
$this->post->update([...]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(): void
|
||||||
|
{
|
||||||
|
// Different authorization for publish
|
||||||
|
$this->authorize('publish', $this->post);
|
||||||
|
|
||||||
|
$this->post->update(['status' => 'published']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $this->post);
|
||||||
|
|
||||||
|
$this->post->delete();
|
||||||
|
$this->redirect(route('admin.blog.posts'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal with File Uploads
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
class MediaUploader extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public $image;
|
||||||
|
public string $altText = '';
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'image' => 'required|image|max:5120', // 5MB max
|
||||||
|
'altText' => 'required|string|max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function upload(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$path = $this->image->store('media', 'public');
|
||||||
|
|
||||||
|
Media::create([
|
||||||
|
'path' => $path,
|
||||||
|
'alt_text' => $this->altText,
|
||||||
|
'mime_type' => $this->image->getMimeType(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->dispatch('media-uploaded');
|
||||||
|
$this->reset(['image', 'altText']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authorization Integration
|
||||||
|
|
||||||
|
### Form Component Authorization Props
|
||||||
|
|
||||||
|
All form components support authorization via `canGate` and `canResource` props:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Button disabled if user cannot update post --}}
|
||||||
|
<x-forms.button
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Input disabled if user cannot update --}}
|
||||||
|
<x-forms.input
|
||||||
|
id="title"
|
||||||
|
wire:model="title"
|
||||||
|
label="Title"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Textarea with authorization --}}
|
||||||
|
<x-forms.textarea
|
||||||
|
id="content"
|
||||||
|
wire:model="content"
|
||||||
|
label="Content"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Select with authorization --}}
|
||||||
|
<x-forms.select
|
||||||
|
id="status"
|
||||||
|
wire:model="status"
|
||||||
|
label="Status"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
|
||||||
|
{{-- Toggle with authorization --}}
|
||||||
|
<x-forms.toggle
|
||||||
|
id="featured"
|
||||||
|
wire:model="featured"
|
||||||
|
label="Featured"
|
||||||
|
canGate="update"
|
||||||
|
:canResource="$post"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blade Conditional Rendering
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Show only if user can create --}}
|
||||||
|
@can('create', App\Models\Post::class)
|
||||||
|
<a href="{{ route('admin.blog.posts.create') }}">New Post</a>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
{{-- Show if user can edit OR delete --}}
|
||||||
|
@canany(['update', 'delete'], $post)
|
||||||
|
<div class="actions">
|
||||||
|
@can('update', $post)
|
||||||
|
<a href="{{ route('admin.blog.posts.edit', $post) }}">Edit</a>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@can('delete', $post)
|
||||||
|
<button wire:click="delete">Delete</button>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
@endcanany
|
||||||
|
|
||||||
|
{{-- Show message if cannot edit --}}
|
||||||
|
@cannot('update', $post)
|
||||||
|
<p class="text-gray-500">You cannot edit this post.</p>
|
||||||
|
@endcannot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Policies
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Policies;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
class PostPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check workspace boundary for all actions.
|
||||||
|
*/
|
||||||
|
public function before(User $user, string $ability, mixed $model = null): ?bool
|
||||||
|
{
|
||||||
|
// Admins bypass all checks
|
||||||
|
if ($user->isHades()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce workspace isolation
|
||||||
|
if ($model instanceof Post && $user->workspace_id !== $model->workspace_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Continue to specific method
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.view');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.view');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.edit')
|
||||||
|
|| $user->id === $post->author_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasRole('admin')
|
||||||
|
|| ($user->hasPermission('posts.delete') && $user->id === $post->author_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(User $user, Post $post): bool
|
||||||
|
{
|
||||||
|
return $user->hasPermission('posts.publish')
|
||||||
|
&& $post->status !== 'archived';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Module Example
|
||||||
|
|
||||||
|
Here is a complete example of an admin module with menus, modals, and authorization.
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Mod/Blog/
|
||||||
|
├── Boot.php
|
||||||
|
├── Models/
|
||||||
|
│ └── Post.php
|
||||||
|
├── Policies/
|
||||||
|
│ └── PostPolicy.php
|
||||||
|
├── View/
|
||||||
|
│ ├── Blade/
|
||||||
|
│ │ └── admin/
|
||||||
|
│ │ ├── posts-list.blade.php
|
||||||
|
│ │ └── post-editor.blade.php
|
||||||
|
│ └── Modal/
|
||||||
|
│ └── Admin/
|
||||||
|
│ ├── PostsList.php
|
||||||
|
│ └── PostEditor.php
|
||||||
|
└── Routes/
|
||||||
|
└── admin.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Boot.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Core\Front\Admin\AdminMenuRegistry;
|
||||||
|
use Core\Front\Admin\Concerns\HasMenuPermissions;
|
||||||
|
use Core\Front\Admin\Contracts\AdminMenuProvider;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
use Mod\Blog\Policies\PostPolicy;
|
||||||
|
|
||||||
|
class Boot extends ServiceProvider implements AdminMenuProvider
|
||||||
|
{
|
||||||
|
use HasMenuPermissions;
|
||||||
|
|
||||||
|
public static array $listens = [
|
||||||
|
AdminPanelBooting::class => 'onAdminPanel',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Register policy
|
||||||
|
Gate::policy(Post::class, PostPolicy::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
// Views
|
||||||
|
$event->views('blog', __DIR__.'/View/Blade');
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||||
|
|
||||||
|
// Menu
|
||||||
|
app(AdminMenuRegistry::class)->register($this);
|
||||||
|
|
||||||
|
// Livewire components
|
||||||
|
$event->livewire('blog.admin.posts-list', View\Modal\Admin\PostsList::class);
|
||||||
|
$event->livewire('blog.admin.post-editor', View\Modal\Admin\PostEditor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminMenuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'group' => 'services',
|
||||||
|
'priority' => self::PRIORITY_NORMAL,
|
||||||
|
'entitlement' => 'core.srv.blog',
|
||||||
|
'permissions' => ['posts.view'],
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Blog',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'href' => route('admin.blog.posts'),
|
||||||
|
'active' => request()->routeIs('admin.blog.*'),
|
||||||
|
'color' => 'blue',
|
||||||
|
'badge' => $this->getDraftCount(),
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'label' => 'All Posts',
|
||||||
|
'href' => route('admin.blog.posts'),
|
||||||
|
'icon' => 'document-text',
|
||||||
|
'active' => request()->routeIs('admin.blog.posts'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Create Post',
|
||||||
|
'href' => route('admin.blog.posts.create'),
|
||||||
|
'icon' => 'plus',
|
||||||
|
'active' => request()->routeIs('admin.blog.posts.create'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDraftCount(): ?int
|
||||||
|
{
|
||||||
|
$count = Post::draft()->count();
|
||||||
|
return $count > 0 ? $count : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes/admin.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostEditor;
|
||||||
|
use Mod\Blog\View\Modal\Admin\PostsList;
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'admin'])
|
||||||
|
->prefix('admin/blog')
|
||||||
|
->name('admin.blog.')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/posts', PostsList::class)->name('posts');
|
||||||
|
Route::get('/posts/create', PostEditor::class)->name('posts.create');
|
||||||
|
Route::get('/posts/{post}/edit', PostEditor::class)->name('posts.edit');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### View/Modal/Admin/PostsList.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
#[Title('Blog Posts')]
|
||||||
|
#[Layout('admin::layouts.app')]
|
||||||
|
class PostsList extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
public string $status = '';
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function posts()
|
||||||
|
{
|
||||||
|
return Post::query()
|
||||||
|
->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%"))
|
||||||
|
->when($this->status, fn ($q) => $q->where('status', $this->status))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $postId): void
|
||||||
|
{
|
||||||
|
$post = Post::findOrFail($postId);
|
||||||
|
|
||||||
|
$this->authorize('delete', $post);
|
||||||
|
|
||||||
|
$post->delete();
|
||||||
|
|
||||||
|
session()->flash('success', 'Post deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('blog::admin.posts-list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### View/Blade/admin/posts-list.blade.php
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold">Blog Posts</h1>
|
||||||
|
|
||||||
|
@can('create', \Mod\Blog\Models\Post::class)
|
||||||
|
<a href="{{ route('admin.blog.posts.create') }}" class="btn-primary">
|
||||||
|
<x-icon name="plus" class="w-4 h-4 mr-2" />
|
||||||
|
New Post
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::header>
|
||||||
|
|
||||||
|
<x-hlcrf::content>
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="mb-6 flex gap-4">
|
||||||
|
<x-forms.input
|
||||||
|
id="search"
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
placeholder="Search posts..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-forms.select id="status" wire:model.live="status">
|
||||||
|
<flux:select.option value="">All Statuses</flux:select.option>
|
||||||
|
<flux:select.option value="draft">Draft</flux:select.option>
|
||||||
|
<flux:select.option value="published">Published</flux:select.option>
|
||||||
|
</x-forms.select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Posts table --}}
|
||||||
|
<div class="bg-white rounded-lg shadow">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@forelse($this->posts as $post)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4">{{ $post->title }}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="badge badge-{{ $post->status === 'published' ? 'green' : 'gray' }}">
|
||||||
|
{{ ucfirst($post->status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">{{ $post->created_at->format('M d, Y') }}</td>
|
||||||
|
<td class="px-6 py-4 text-right space-x-2">
|
||||||
|
@can('update', $post)
|
||||||
|
<a href="{{ route('admin.blog.posts.edit', $post) }}" class="text-blue-600 hover:text-blue-800">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@can('delete', $post)
|
||||||
|
<button
|
||||||
|
wire:click="delete({{ $post->id }})"
|
||||||
|
wire:confirm="Delete this post?"
|
||||||
|
class="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
@endcan
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
|
||||||
|
No posts found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Pagination --}}
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $this->posts->links() }}
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Use Entitlements for Services
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Menu item requires workspace entitlement
|
||||||
|
[
|
||||||
|
'group' => 'services',
|
||||||
|
'entitlement' => 'core.srv.blog', // Required
|
||||||
|
'item' => fn () => [...],
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Authorize Early in Modals
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function mount(Post $post): void
|
||||||
|
{
|
||||||
|
$this->authorize('update', $post); // Fail fast
|
||||||
|
$this->post = $post;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Form Component Authorization Props
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Declarative authorization --}}
|
||||||
|
<x-forms.button canGate="update" :canResource="$post">
|
||||||
|
Save
|
||||||
|
</x-forms.button>
|
||||||
|
|
||||||
|
{{-- Not manual checks --}}
|
||||||
|
@if(auth()->user()->can('update', $post))
|
||||||
|
<button>Save</button>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Keep Menu Items Lazy
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Item closure is only evaluated when rendered
|
||||||
|
'item' => fn () => [
|
||||||
|
'label' => 'Posts',
|
||||||
|
'badge' => Post::draft()->count(), // Computed at render time
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Use HLCRF for Consistent Layouts
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Always use HLCRF for admin views --}}
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::header>...</x-hlcrf::header>
|
||||||
|
<x-hlcrf::content>...</x-hlcrf::content>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Admin Menus](/packages/admin/menus)
|
||||||
|
- [Livewire Modals](/packages/admin/modals)
|
||||||
|
- [Form Components](/packages/admin/forms)
|
||||||
|
- [Authorization](/packages/admin/authorization)
|
||||||
|
- [HLCRF Layouts](/packages/admin/hlcrf-deep-dive)
|
||||||
843
docs/packages/admin/hlcrf-deep-dive.md
Normal file
843
docs/packages/admin/hlcrf-deep-dive.md
Normal file
|
|
@ -0,0 +1,843 @@
|
||||||
|
# HLCRF Deep Dive
|
||||||
|
|
||||||
|
This guide provides an in-depth look at the HLCRF (Header-Left-Content-Right-Footer) layout system, covering all layout combinations, the ID system, responsive patterns, and complex real-world examples.
|
||||||
|
|
||||||
|
## Layout Combinations
|
||||||
|
|
||||||
|
HLCRF supports any combination of its five regions. The variant name describes which regions are present.
|
||||||
|
|
||||||
|
### All Possible Combinations
|
||||||
|
|
||||||
|
| Variant | Regions | Use Case |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| `C` | Content only | Simple content pages |
|
||||||
|
| `HC` | Header + Content | Landing pages |
|
||||||
|
| `CF` | Content + Footer | Article pages |
|
||||||
|
| `HCF` | Header + Content + Footer | Standard pages |
|
||||||
|
| `LC` | Left + Content | App with navigation |
|
||||||
|
| `CR` | Content + Right | Content with sidebar |
|
||||||
|
| `LCR` | Left + Content + Right | Three-column layout |
|
||||||
|
| `HLC` | Header + Left + Content | Admin dashboard |
|
||||||
|
| `HCR` | Header + Content + Right | Blog with widgets |
|
||||||
|
| `LCF` | Left + Content + Footer | App with footer |
|
||||||
|
| `CRF` | Content + Right + Footer | Blog layout |
|
||||||
|
| `HLCF` | Header + Left + Content + Footer | Standard admin |
|
||||||
|
| `HCRF` | Header + Content + Right + Footer | Blog layout |
|
||||||
|
| `HLCR` | Header + Left + Content + Right | Full admin |
|
||||||
|
| `LCRF` | Left + Content + Right + Footer | Complex app |
|
||||||
|
| `HLCRF` | All five regions | Complete layout |
|
||||||
|
|
||||||
|
### Content-Only (C)
|
||||||
|
|
||||||
|
Minimal layout for simple content:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Components\Layout;
|
||||||
|
|
||||||
|
$layout = Layout::make('C')
|
||||||
|
->c('<main>Simple content without chrome</main>');
|
||||||
|
|
||||||
|
echo $layout->render();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```html
|
||||||
|
<div class="hlcrf-layout" data-layout="root">
|
||||||
|
<div class="hlcrf-body flex flex-1">
|
||||||
|
<main class="hlcrf-content flex-1" data-slot="C">
|
||||||
|
<div data-block="C-0">
|
||||||
|
<main>Simple content without chrome</main>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header + Content + Footer (HCF)
|
||||||
|
|
||||||
|
Standard page layout:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('HCF')
|
||||||
|
->h('<nav>Site Navigation</nav>')
|
||||||
|
->c('<article>Page Content</article>')
|
||||||
|
->f('<footer>Copyright 2026</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Left + Content (LC)
|
||||||
|
|
||||||
|
Application with navigation sidebar:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LC')
|
||||||
|
->l('<nav class="w-64">App Menu</nav>')
|
||||||
|
->c('<main>App Content</main>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Three-Column (LCR)
|
||||||
|
|
||||||
|
Full three-column layout:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LCR')
|
||||||
|
->l('<nav>Navigation</nav>')
|
||||||
|
->c('<main>Content</main>')
|
||||||
|
->r('<aside>Widgets</aside>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Admin (HLCRF)
|
||||||
|
|
||||||
|
Complete admin panel:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('HLCRF')
|
||||||
|
->h('<header>Admin Header</header>')
|
||||||
|
->l('<nav>Sidebar</nav>')
|
||||||
|
->c('<main>Dashboard</main>')
|
||||||
|
->r('<aside>Quick Actions</aside>')
|
||||||
|
->f('<footer>Status Bar</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
## The ID System
|
||||||
|
|
||||||
|
Every HLCRF element receives a unique, hierarchical ID that describes its position in the layout tree.
|
||||||
|
|
||||||
|
### ID Format
|
||||||
|
|
||||||
|
```
|
||||||
|
{Region}-{Index}[-{NestedRegion}-{NestedIndex}]...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- **Region Letter** - `H`, `L`, `C`, `R`, or `F`
|
||||||
|
- **Index** - Zero-based position within that slot (0, 1, 2, ...)
|
||||||
|
- **Nesting** - Dash-separated chain for nested layouts
|
||||||
|
|
||||||
|
### Region Letters
|
||||||
|
|
||||||
|
| Letter | Region | Semantic Role |
|
||||||
|
|--------|--------|---------------|
|
||||||
|
| `H` | Header | Top navigation, branding |
|
||||||
|
| `L` | Left | Primary sidebar, navigation |
|
||||||
|
| `C` | Content | Main content area |
|
||||||
|
| `R` | Right | Secondary sidebar, widgets |
|
||||||
|
| `F` | Footer | Bottom links, copyright |
|
||||||
|
|
||||||
|
### ID Examples
|
||||||
|
|
||||||
|
**Simple layout:**
|
||||||
|
```html
|
||||||
|
<div data-layout="root">
|
||||||
|
<header data-slot="H">
|
||||||
|
<div data-block="H-0">First header element</div>
|
||||||
|
<div data-block="H-1">Second header element</div>
|
||||||
|
</header>
|
||||||
|
<main data-slot="C">
|
||||||
|
<div data-block="C-0">First content element</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested layout:**
|
||||||
|
```html
|
||||||
|
<div data-layout="root">
|
||||||
|
<main data-slot="C">
|
||||||
|
<div data-block="C-0">
|
||||||
|
<!-- Nested layout inside content -->
|
||||||
|
<div data-layout="C-0-">
|
||||||
|
<aside data-slot="C-0-L">
|
||||||
|
<div data-block="C-0-L-0">Nested left sidebar</div>
|
||||||
|
</aside>
|
||||||
|
<main data-slot="C-0-C">
|
||||||
|
<div data-block="C-0-C-0">Nested content</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ID Interpretation
|
||||||
|
|
||||||
|
| ID | Meaning |
|
||||||
|
|----|---------|
|
||||||
|
| `H-0` | First element in Header |
|
||||||
|
| `L-2` | Third element in Left sidebar |
|
||||||
|
| `C-0` | First element in Content |
|
||||||
|
| `C-L-0` | Content > Left > First element |
|
||||||
|
| `C-R-2` | Content > Right > Third element |
|
||||||
|
| `C-L-0-R-1` | Content > Left > First > Right > Second |
|
||||||
|
| `H-0-C-0-L-0` | Header > Content > Left (deeply nested) |
|
||||||
|
|
||||||
|
### Using IDs for CSS
|
||||||
|
|
||||||
|
The ID system enables precise CSS targeting:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Target first header element */
|
||||||
|
[data-block="H-0"] {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target all elements in left sidebar */
|
||||||
|
[data-slot="L"] > [data-block] {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target nested content areas */
|
||||||
|
[data-block*="-C-"] {
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target second element in any right sidebar */
|
||||||
|
[data-block$="-R-1"] {
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target deeply nested layouts */
|
||||||
|
[data-layout*="-"][data-layout*="-"] {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using IDs for Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
// PHPUnit/Pest
|
||||||
|
$this->assertSee('[data-block="H-0"]');
|
||||||
|
$this->assertSeeInOrder(['[data-slot="L"]', '[data-slot="C"]']);
|
||||||
|
|
||||||
|
// Playwright/Cypress
|
||||||
|
await page.locator('[data-block="C-0"]').click();
|
||||||
|
await expect(page.locator('[data-slot="R"]')).toBeVisible();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using IDs for JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Target specific elements
|
||||||
|
const header = document.querySelector('[data-block="H-0"]');
|
||||||
|
const sidebar = document.querySelector('[data-slot="L"]');
|
||||||
|
|
||||||
|
// Dynamic targeting
|
||||||
|
function getContentBlock(index) {
|
||||||
|
return document.querySelector(`[data-block="C-${index}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested targeting
|
||||||
|
const nestedLeft = document.querySelector('[data-block="C-L-0"]');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Design Patterns
|
||||||
|
|
||||||
|
### Mobile-First Stacking
|
||||||
|
|
||||||
|
On mobile, stack regions vertically:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout
|
||||||
|
:breakpoints="[
|
||||||
|
'mobile' => 'stack',
|
||||||
|
'tablet' => 'LC',
|
||||||
|
'desktop' => 'LCR',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<x-hlcrf::left>Navigation</x-hlcrf::left>
|
||||||
|
<x-hlcrf::content>Content</x-hlcrf::content>
|
||||||
|
<x-hlcrf::right>Widgets</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- **Mobile (< 768px):** Left -> Content -> Right (vertical)
|
||||||
|
- **Tablet (768px-1024px):** Left | Content (two columns)
|
||||||
|
- **Desktop (> 1024px):** Left | Content | Right (three columns)
|
||||||
|
|
||||||
|
### Collapsible Sidebars
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::left
|
||||||
|
collapsible="true"
|
||||||
|
collapsed-width="64px"
|
||||||
|
expanded-width="256px"
|
||||||
|
:collapsed="$sidebarCollapsed"
|
||||||
|
>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
@if(!$sidebarCollapsed)
|
||||||
|
<span>Full navigation content</span>
|
||||||
|
@else
|
||||||
|
<span>Icons only</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::left>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hidden Regions on Mobile
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::right
|
||||||
|
class="hidden md:block"
|
||||||
|
width="300px"
|
||||||
|
>
|
||||||
|
{{-- Only visible on medium screens and up --}}
|
||||||
|
<x-widget-panel />
|
||||||
|
</x-hlcrf::right>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flexible Width Distribution
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::layout>
|
||||||
|
<x-hlcrf::left width="250px" class="shrink-0">
|
||||||
|
Fixed-width sidebar
|
||||||
|
</x-hlcrf::left>
|
||||||
|
|
||||||
|
<x-hlcrf::content class="flex-1 min-w-0">
|
||||||
|
Flexible content
|
||||||
|
</x-hlcrf::content>
|
||||||
|
|
||||||
|
<x-hlcrf::right width="25%" class="shrink-0">
|
||||||
|
Percentage-width sidebar
|
||||||
|
</x-hlcrf::right>
|
||||||
|
</x-hlcrf::layout>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Grid Inside Content
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-hlcrf::content>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<x-stat-card title="Users" :value="$userCount" />
|
||||||
|
<x-stat-card title="Posts" :value="$postCount" />
|
||||||
|
<x-stat-card title="Comments" :value="$commentCount" />
|
||||||
|
</div>
|
||||||
|
</x-hlcrf::content>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Real-World Examples
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
|
||||||
|
A complete admin dashboard with nested layouts:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Components\Layout;
|
||||||
|
|
||||||
|
// Main admin layout
|
||||||
|
$admin = Layout::make('HLCF')
|
||||||
|
->h(
|
||||||
|
'<nav class="flex items-center justify-between px-4 py-2 bg-gray-900 text-white">
|
||||||
|
<div class="logo">Admin Panel</div>
|
||||||
|
<div class="user-menu">
|
||||||
|
<span>user@example.com</span>
|
||||||
|
</div>
|
||||||
|
</nav>'
|
||||||
|
)
|
||||||
|
->l(
|
||||||
|
'<nav class="w-64 bg-gray-800 text-gray-300 min-h-screen p-4">
|
||||||
|
<a href="/dashboard" class="block py-2">Dashboard</a>
|
||||||
|
<a href="/users" class="block py-2">Users</a>
|
||||||
|
<a href="/settings" class="block py-2">Settings</a>
|
||||||
|
</nav>'
|
||||||
|
)
|
||||||
|
->c(
|
||||||
|
// Nested layout inside content
|
||||||
|
Layout::make('HCR')
|
||||||
|
->h('<div class="flex items-center justify-between p-4 border-b">
|
||||||
|
<h1 class="text-xl font-semibold">Dashboard</h1>
|
||||||
|
<button class="btn-primary">New Item</button>
|
||||||
|
</div>')
|
||||||
|
->c('<div class="p-6">
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-white p-4 rounded shadow">Stat 1</div>
|
||||||
|
<div class="bg-white p-4 rounded shadow">Stat 2</div>
|
||||||
|
<div class="bg-white p-4 rounded shadow">Stat 3</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-4 rounded shadow">
|
||||||
|
<h2 class="font-medium mb-4">Recent Activity</h2>
|
||||||
|
<table class="w-full">...</table>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->r('<aside class="w-80 p-4 bg-gray-50 border-l">
|
||||||
|
<h3 class="font-medium mb-4">Quick Actions</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button class="w-full btn-secondary">Export Data</button>
|
||||||
|
<button class="w-full btn-secondary">Generate Report</button>
|
||||||
|
</div>
|
||||||
|
</aside>')
|
||||||
|
)
|
||||||
|
->f(
|
||||||
|
'<footer class="px-4 py-2 bg-gray-100 text-gray-600 text-sm">
|
||||||
|
Version 1.0.0 | Last sync: 5 minutes ago
|
||||||
|
</footer>'
|
||||||
|
);
|
||||||
|
|
||||||
|
echo $admin->render();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated IDs:**
|
||||||
|
- `H-0` - Admin header/navigation
|
||||||
|
- `L-0` - Sidebar navigation
|
||||||
|
- `C-0` - Nested layout container
|
||||||
|
- `C-0-H-0` - Content header (page title/actions)
|
||||||
|
- `C-0-C-0` - Content main area (stats/table)
|
||||||
|
- `C-0-R-0` - Content right sidebar (quick actions)
|
||||||
|
- `F-0` - Admin footer
|
||||||
|
|
||||||
|
### E-Commerce Product Page
|
||||||
|
|
||||||
|
Product page with nested sections:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$productPage = Layout::make('HCF')
|
||||||
|
->h('<header class="border-b">
|
||||||
|
<nav>Store Navigation</nav>
|
||||||
|
<div>Search | Cart | Account</div>
|
||||||
|
</header>')
|
||||||
|
->c(
|
||||||
|
Layout::make('LCR')
|
||||||
|
->l('<div class="w-1/2">
|
||||||
|
<div class="aspect-square bg-gray-100">
|
||||||
|
<img src="/product-main.jpg" alt="Product" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<img src="/thumb-1.jpg" class="w-16 h-16" />
|
||||||
|
<img src="/thumb-2.jpg" class="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->c(
|
||||||
|
// Empty - using left/right only
|
||||||
|
)
|
||||||
|
->r('<div class="w-1/2 p-6">
|
||||||
|
<h1 class="text-2xl font-bold">Product Name</h1>
|
||||||
|
<p class="text-xl text-green-600 mt-2">$99.99</p>
|
||||||
|
<p class="mt-4">Product description...</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<select>Size options</select>
|
||||||
|
<button class="w-full btn-primary">Add to Cart</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t pt-4">
|
||||||
|
<h3>Shipping Info</h3>
|
||||||
|
<p>Free delivery over $50</p>
|
||||||
|
</div>
|
||||||
|
</div>'),
|
||||||
|
// Reviews section
|
||||||
|
Layout::make('CR')
|
||||||
|
->c('<div class="p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Customer Reviews</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border-b pb-4">Review 1...</div>
|
||||||
|
<div class="border-b pb-4">Review 2...</div>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->r('<aside class="w-64 p-4 bg-gray-50">
|
||||||
|
<h3>You May Also Like</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>Related Product 1</div>
|
||||||
|
<div>Related Product 2</div>
|
||||||
|
</div>
|
||||||
|
</aside>')
|
||||||
|
)
|
||||||
|
->f('<footer class="bg-gray-900 text-white p-8">
|
||||||
|
<div class="grid grid-cols-4 gap-8">
|
||||||
|
<div>About Us</div>
|
||||||
|
<div>Customer Service</div>
|
||||||
|
<div>Policies</div>
|
||||||
|
<div>Newsletter</div>
|
||||||
|
</div>
|
||||||
|
</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Panel Settings Page
|
||||||
|
|
||||||
|
Settings page with multiple nested panels:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$settings = Layout::make('HLC')
|
||||||
|
->h('<header class="border-b p-4">
|
||||||
|
<h1>Account Settings</h1>
|
||||||
|
</header>')
|
||||||
|
->l('<nav class="w-48 border-r">
|
||||||
|
<a href="#profile" class="block p-3 bg-blue-50">Profile</a>
|
||||||
|
<a href="#security" class="block p-3">Security</a>
|
||||||
|
<a href="#notifications" class="block p-3">Notifications</a>
|
||||||
|
<a href="#billing" class="block p-3">Billing</a>
|
||||||
|
</nav>')
|
||||||
|
->c(
|
||||||
|
// Profile section
|
||||||
|
Layout::make('HCF')
|
||||||
|
->h('<div class="p-4 border-b">
|
||||||
|
<h2 class="font-semibold">Profile Information</h2>
|
||||||
|
<p class="text-gray-600 text-sm">Update your account details</p>
|
||||||
|
</div>')
|
||||||
|
->c('<form class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" value="John Doe" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" value="john@example.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Bio</label>
|
||||||
|
<textarea rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>')
|
||||||
|
->f('<div class="p-4 border-t bg-gray-50 flex justify-end gap-2">
|
||||||
|
<button class="btn-secondary">Cancel</button>
|
||||||
|
<button class="btn-primary">Save Changes</button>
|
||||||
|
</div>')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation Site
|
||||||
|
|
||||||
|
Documentation layout with table of contents:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$docs = Layout::make('HLCRF')
|
||||||
|
->h('<header class="border-b">
|
||||||
|
<div class="flex items-center justify-between px-6 py-3">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img src="/logo.svg" class="h-8" />
|
||||||
|
<nav class="hidden md:flex gap-6">
|
||||||
|
<a href="/docs">Docs</a>
|
||||||
|
<a href="/api">API</a>
|
||||||
|
<a href="/examples">Examples</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input type="search" placeholder="Search..." />
|
||||||
|
<a href="/github">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>')
|
||||||
|
->l('<nav class="w-64 p-4 border-r overflow-y-auto">
|
||||||
|
<h4 class="font-semibold text-gray-500 uppercase text-xs mb-2">Getting Started</h4>
|
||||||
|
<a href="/docs/intro" class="block py-1 text-blue-600">Introduction</a>
|
||||||
|
<a href="/docs/install" class="block py-1">Installation</a>
|
||||||
|
<a href="/docs/quick-start" class="block py-1">Quick Start</a>
|
||||||
|
|
||||||
|
<h4 class="font-semibold text-gray-500 uppercase text-xs mt-6 mb-2">Core Concepts</h4>
|
||||||
|
<a href="/docs/layouts" class="block py-1">Layouts</a>
|
||||||
|
<a href="/docs/components" class="block py-1">Components</a>
|
||||||
|
<a href="/docs/routing" class="block py-1">Routing</a>
|
||||||
|
</nav>')
|
||||||
|
->c('<article class="prose max-w-3xl mx-auto p-8">
|
||||||
|
<h1>Introduction</h1>
|
||||||
|
<p>Welcome to the documentation...</p>
|
||||||
|
|
||||||
|
<h2>Key Features</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Feature 1</li>
|
||||||
|
<li>Feature 2</li>
|
||||||
|
<li>Feature 3</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Next Steps</h2>
|
||||||
|
<p>Continue to the installation guide...</p>
|
||||||
|
</article>')
|
||||||
|
->r('<aside class="w-48 p-4 border-l">
|
||||||
|
<h4 class="font-semibold text-sm mb-2">On This Page</h4>
|
||||||
|
<nav class="text-sm space-y-1">
|
||||||
|
<a href="#intro" class="block text-gray-600">Introduction</a>
|
||||||
|
<a href="#features" class="block text-gray-600">Key Features</a>
|
||||||
|
<a href="#next" class="block text-gray-600">Next Steps</a>
|
||||||
|
</nav>
|
||||||
|
</aside>')
|
||||||
|
->f('<footer class="border-t p-4 flex justify-between text-sm text-gray-600">
|
||||||
|
<div>
|
||||||
|
<a href="/prev" class="text-blue-600">← Previous: Setup</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/next" class="text-blue-600">Next: Installation →</a>
|
||||||
|
</div>
|
||||||
|
</footer>');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Client Interface
|
||||||
|
|
||||||
|
Complex email client with multiple nested panels:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$email = Layout::make('HLCR')
|
||||||
|
->h('<header class="bg-white border-b px-4 py-2 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="btn-icon">Menu</button>
|
||||||
|
<img src="/logo.svg" class="h-6" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 max-w-2xl mx-4">
|
||||||
|
<input type="search" placeholder="Search mail" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="btn-icon">Settings</button>
|
||||||
|
<div class="avatar">JD</div>
|
||||||
|
</div>
|
||||||
|
</header>')
|
||||||
|
->l('<aside class="w-64 border-r flex flex-col">
|
||||||
|
<div class="p-3">
|
||||||
|
<button class="w-full btn-primary">Compose</button>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 overflow-y-auto">
|
||||||
|
<a href="#inbox" class="flex items-center gap-3 px-4 py-2 bg-blue-50">
|
||||||
|
<span class="icon">inbox</span>
|
||||||
|
<span class="flex-1">Inbox</span>
|
||||||
|
<span class="badge">12</span>
|
||||||
|
</a>
|
||||||
|
<a href="#starred" class="flex items-center gap-3 px-4 py-2">
|
||||||
|
<span class="icon">star</span>
|
||||||
|
<span>Starred</span>
|
||||||
|
</a>
|
||||||
|
<a href="#sent" class="flex items-center gap-3 px-4 py-2">
|
||||||
|
<span class="icon">send</span>
|
||||||
|
<span>Sent</span>
|
||||||
|
</a>
|
||||||
|
<a href="#drafts" class="flex items-center gap-3 px-4 py-2">
|
||||||
|
<span class="icon">draft</span>
|
||||||
|
<span>Drafts</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="p-4 border-t text-sm text-gray-600">
|
||||||
|
Storage: 2.4 GB / 15 GB
|
||||||
|
</div>
|
||||||
|
</aside>')
|
||||||
|
->c(
|
||||||
|
Layout::make('LC')
|
||||||
|
->l('<div class="w-80 border-r overflow-y-auto">
|
||||||
|
<div class="p-2 border-b">
|
||||||
|
<select class="w-full text-sm">
|
||||||
|
<option>All Mail</option>
|
||||||
|
<option>Unread</option>
|
||||||
|
<option>Starred</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y">
|
||||||
|
<div class="p-3 bg-blue-50 cursor-pointer">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">John Smith</span>
|
||||||
|
<span class="text-xs text-gray-500">10:30 AM</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-medium text-sm">Meeting Tomorrow</div>
|
||||||
|
<div class="text-sm text-gray-600 truncate">Hi, just wanted to confirm...</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 cursor-pointer">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium">Jane Doe</span>
|
||||||
|
<span class="text-xs text-gray-500">Yesterday</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-medium text-sm">Project Update</div>
|
||||||
|
<div class="text-sm text-gray-600 truncate">Here is the latest update...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
->c('<div class="flex-1 flex flex-col">
|
||||||
|
<div class="p-4 border-b flex items-center gap-2">
|
||||||
|
<button class="btn-icon">Archive</button>
|
||||||
|
<button class="btn-icon">Delete</button>
|
||||||
|
<button class="btn-icon">Move</button>
|
||||||
|
<span class="border-l h-6 mx-2"></span>
|
||||||
|
<button class="btn-icon">Reply</button>
|
||||||
|
<button class="btn-icon">Forward</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-medium">Meeting Tomorrow</h2>
|
||||||
|
<div class="flex items-center gap-3 mt-2 text-sm text-gray-600">
|
||||||
|
<div class="avatar">JS</div>
|
||||||
|
<div>
|
||||||
|
<div>John Smith <john@example.com></div>
|
||||||
|
<div>to me</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto">Jan 15, 2026, 10:30 AM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prose">
|
||||||
|
<p>Hi,</p>
|
||||||
|
<p>Just wanted to confirm our meeting tomorrow at 2pm.</p>
|
||||||
|
<p>Best regards,<br>John</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>')
|
||||||
|
)
|
||||||
|
->r('<aside class="w-64 border-l p-4 hidden xl:block">
|
||||||
|
<h3 class="font-medium mb-4">Contact Info</h3>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<div>John Smith</div>
|
||||||
|
<div class="text-gray-600">john@example.com</div>
|
||||||
|
<div class="text-gray-600">+1 555 123 4567</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-medium mt-6 mb-4">Related Emails</h3>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<a href="#" class="block text-blue-600">Re: Project Timeline</a>
|
||||||
|
<a href="#" class="block text-blue-600">Meeting Notes</a>
|
||||||
|
</div>
|
||||||
|
</aside>');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Lazy Content Loading
|
||||||
|
|
||||||
|
For large layouts, defer non-critical content:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LCR')
|
||||||
|
->l('<nav>Immediate navigation</nav>')
|
||||||
|
->c('<main wire:init="loadContent">
|
||||||
|
<div wire:loading>Loading...</div>
|
||||||
|
<div wire:loading.remove>@livewire("content-panel")</div>
|
||||||
|
</main>')
|
||||||
|
->r(fn () => view('widgets.sidebar')); // Closure defers evaluation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Region Rendering
|
||||||
|
|
||||||
|
Only render regions when needed:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$layout = Layout::make('LCR');
|
||||||
|
|
||||||
|
$layout->l('<nav>Navigation</nav>');
|
||||||
|
$layout->c('<main>Content</main>');
|
||||||
|
|
||||||
|
// Conditionally add right sidebar
|
||||||
|
if ($user->hasFeature('widgets')) {
|
||||||
|
$layout->r('<aside>Widgets</aside>');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Efficient CSS Targeting
|
||||||
|
|
||||||
|
Use data attributes instead of deep selectors:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Efficient - uses data attribute */
|
||||||
|
[data-block="C-0"] { padding: 1rem; }
|
||||||
|
|
||||||
|
/* Less efficient - deep selector */
|
||||||
|
.hlcrf-layout > .hlcrf-body > .hlcrf-content > div:first-child { padding: 1rem; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing HLCRF Layouts
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Front\Components\Layout;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LayoutTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_generates_correct_ids(): void
|
||||||
|
{
|
||||||
|
$layout = Layout::make('LC')
|
||||||
|
->l('Left')
|
||||||
|
->c('Content');
|
||||||
|
|
||||||
|
$html = $layout->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('data-slot="L"', $html);
|
||||||
|
$this->assertStringContainsString('data-slot="C"', $html);
|
||||||
|
$this->assertStringContainsString('data-block="L-0"', $html);
|
||||||
|
$this->assertStringContainsString('data-block="C-0"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nested_layout_ids(): void
|
||||||
|
{
|
||||||
|
$nested = Layout::make('LR')
|
||||||
|
->l('Nested Left')
|
||||||
|
->r('Nested Right');
|
||||||
|
|
||||||
|
$outer = Layout::make('C')
|
||||||
|
->c($nested);
|
||||||
|
|
||||||
|
$html = $outer->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('data-block="C-0-L-0"', $html);
|
||||||
|
$this->assertStringContainsString('data-block="C-0-R-0"', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Pest with Playwright
|
||||||
|
it('renders admin layout correctly', function () {
|
||||||
|
$this->browse(function ($browser) {
|
||||||
|
$browser->visit('/admin')
|
||||||
|
->assertPresent('[data-layout="root"]')
|
||||||
|
->assertPresent('[data-slot="H"]')
|
||||||
|
->assertPresent('[data-slot="L"]')
|
||||||
|
->assertPresent('[data-slot="C"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Semantic Region Names
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - semantic use
|
||||||
|
->h('<nav>Global navigation</nav>')
|
||||||
|
->l('<nav>Page navigation</nav>')
|
||||||
|
->c('<main>Page content</main>')
|
||||||
|
->r('<aside>Related content</aside>')
|
||||||
|
->f('<footer>Site footer</footer>')
|
||||||
|
|
||||||
|
// Bad - misuse of regions
|
||||||
|
->h('<aside>Sidebar content</aside>') // Header for sidebar?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Leverage the ID System
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Target specific elements precisely */
|
||||||
|
[data-block="H-0"] { /* Header first element */ }
|
||||||
|
[data-block="C-L-0"] { /* Content > Left > First */ }
|
||||||
|
|
||||||
|
/* Don't fight the system with complex selectors */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Keep Nesting Shallow
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - 2-3 levels max
|
||||||
|
Layout::make('HCF')
|
||||||
|
->c(Layout::make('LCR')->...);
|
||||||
|
|
||||||
|
// Avoid - too deep
|
||||||
|
Layout::make('C')
|
||||||
|
->c(Layout::make('C')
|
||||||
|
->c(Layout::make('C')
|
||||||
|
->c(Layout::make('C')...))));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Consistent Widths
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - consistent sidebar widths across app
|
||||||
|
->l('<nav class="w-64">') // Always 256px
|
||||||
|
->r('<aside class="w-80">') // Always 320px
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Handle Empty Regions Gracefully
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Regions without content don't render
|
||||||
|
$layout = Layout::make('LCR')
|
||||||
|
->l('<nav>Nav</nav>')
|
||||||
|
->c('<main>Content</main>');
|
||||||
|
// No ->r() call - right sidebar won't render
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [HLCRF Pattern Overview](/patterns-guide/hlcrf)
|
||||||
|
- [Form Components](/packages/admin/forms)
|
||||||
|
- [Livewire Modals](/packages/admin/modals)
|
||||||
|
- [Creating Admin Panels](/packages/admin/creating-admin-panels)
|
||||||
898
docs/packages/api/building-rest-apis.md
Normal file
898
docs/packages/api/building-rest-apis.md
Normal file
|
|
@ -0,0 +1,898 @@
|
||||||
|
# Building REST APIs
|
||||||
|
|
||||||
|
This guide covers how to build production-ready REST APIs using the core-api package. You'll learn to create resources, implement pagination, add filtering and sorting, and secure endpoints with authentication.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Register API routes by listening to the `ApiRoutesRegistering` event:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\ApiRoutesRegistering;
|
||||||
|
|
||||||
|
class Boot
|
||||||
|
{
|
||||||
|
public static array $listens = [
|
||||||
|
ApiRoutesRegistering::class => 'onApiRoutes',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function onApiRoutes(ApiRoutesRegistering $event): void
|
||||||
|
{
|
||||||
|
$event->routes(function () {
|
||||||
|
Route::apiResource('posts', Api\PostController::class);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Resources
|
||||||
|
|
||||||
|
### API Resources
|
||||||
|
|
||||||
|
Transform Eloquent models into consistent JSON responses using Laravel's API Resources:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PostResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'type' => 'post',
|
||||||
|
'attributes' => [
|
||||||
|
'title' => $this->title,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'excerpt' => $this->excerpt,
|
||||||
|
'content' => $this->when(
|
||||||
|
$request->user()?->tokenCan('posts:read-content'),
|
||||||
|
$this->content
|
||||||
|
),
|
||||||
|
'status' => $this->status,
|
||||||
|
'published_at' => $this->published_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
'relationships' => [
|
||||||
|
'author' => $this->whenLoaded('author', fn () => [
|
||||||
|
'id' => $this->author->id,
|
||||||
|
'name' => $this->author->name,
|
||||||
|
]),
|
||||||
|
'categories' => $this->whenLoaded('categories', fn () =>
|
||||||
|
$this->categories->map(fn ($cat) => [
|
||||||
|
'id' => $cat->id,
|
||||||
|
'name' => $cat->name,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'created_at' => $this->created_at->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at->toIso8601String(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Controllers
|
||||||
|
|
||||||
|
Build controllers that use the `HasApiResponses` trait for consistent error handling:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Core\Mod\Api\Concerns\HasApiResponses;
|
||||||
|
use Core\Mod\Api\Resources\PaginatedCollection;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
use Mod\Blog\Resources\PostResource;
|
||||||
|
|
||||||
|
class PostController extends Controller
|
||||||
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$posts = Post::query()
|
||||||
|
->with(['author', 'categories'])
|
||||||
|
->paginate($request->input('per_page', 25));
|
||||||
|
|
||||||
|
return new PaginatedCollection($posts, PostResource::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Post $post)
|
||||||
|
{
|
||||||
|
$post->load(['author', 'categories']);
|
||||||
|
|
||||||
|
return new PostResource($post);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'content' => 'required|string',
|
||||||
|
'status' => 'in:draft,published',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post = Post::create($validated);
|
||||||
|
|
||||||
|
return $this->createdResponse(
|
||||||
|
new PostResource($post),
|
||||||
|
'Post created successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Post $post)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'string|max:255',
|
||||||
|
'content' => 'string',
|
||||||
|
'status' => 'in:draft,published',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post->update($validated);
|
||||||
|
|
||||||
|
return new PostResource($post);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Post $post)
|
||||||
|
{
|
||||||
|
$post->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
### Using PaginatedCollection
|
||||||
|
|
||||||
|
The `PaginatedCollection` class provides standardized pagination metadata:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Api\Resources\PaginatedCollection;
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$posts = Post::paginate(
|
||||||
|
$request->input('per_page', config('api.pagination.default_per_page', 25))
|
||||||
|
);
|
||||||
|
|
||||||
|
return new PaginatedCollection($posts, PostResource::class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
Paginated responses include comprehensive metadata:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{"id": 1, "type": "post", "attributes": {...}},
|
||||||
|
{"id": 2, "type": "post", "attributes": {...}}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"current_page": 1,
|
||||||
|
"from": 1,
|
||||||
|
"last_page": 10,
|
||||||
|
"per_page": 25,
|
||||||
|
"to": 25,
|
||||||
|
"total": 250
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"first": "https://api.example.com/v1/posts?page=1",
|
||||||
|
"last": "https://api.example.com/v1/posts?page=10",
|
||||||
|
"prev": null,
|
||||||
|
"next": "https://api.example.com/v1/posts?page=2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pagination Best Practices
|
||||||
|
|
||||||
|
**1. Limit Maximum Page Size**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$perPage = min(
|
||||||
|
$request->input('per_page', 25),
|
||||||
|
config('api.pagination.max_per_page', 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new PaginatedCollection(
|
||||||
|
Post::paginate($perPage),
|
||||||
|
PostResource::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Use Cursor Pagination for Large Datasets**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$posts = Post::orderBy('id')
|
||||||
|
->cursorPaginate($request->input('per_page', 25));
|
||||||
|
|
||||||
|
return PostResource::collection($posts);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Include Total Count Conditionally**
|
||||||
|
|
||||||
|
For very large tables, counting can be expensive:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Post::query();
|
||||||
|
|
||||||
|
// Only count if explicitly requested
|
||||||
|
if ($request->boolean('include_total')) {
|
||||||
|
return new PaginatedCollection(
|
||||||
|
$query->paginate($request->input('per_page', 25)),
|
||||||
|
PostResource::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use simple pagination (no total count)
|
||||||
|
return PostResource::collection(
|
||||||
|
$query->simplePaginate($request->input('per_page', 25))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filtering
|
||||||
|
|
||||||
|
### Query Parameter Filters
|
||||||
|
|
||||||
|
Implement flexible filtering with query parameters:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Post::query();
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if ($status = $request->input('status')) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range filters
|
||||||
|
if ($after = $request->input('created_after')) {
|
||||||
|
$query->where('created_at', '>=', $after);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($before = $request->input('created_before')) {
|
||||||
|
$query->where('created_at', '<=', $before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author filter
|
||||||
|
if ($authorId = $request->input('author_id')) {
|
||||||
|
$query->where('author_id', $authorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-text search
|
||||||
|
if ($search = $request->input('search')) {
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('title', 'like', "%{$search}%")
|
||||||
|
->orWhere('content', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PaginatedCollection(
|
||||||
|
$query->paginate($request->input('per_page', 25)),
|
||||||
|
PostResource::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Validation
|
||||||
|
|
||||||
|
Validate filter parameters to prevent errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'status' => 'in:draft,published,archived',
|
||||||
|
'created_after' => 'date|before_or_equal:created_before',
|
||||||
|
'created_before' => 'date',
|
||||||
|
'author_id' => 'integer|exists:users,id',
|
||||||
|
'per_page' => 'integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Apply filters...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reusable Filter Traits
|
||||||
|
|
||||||
|
Create a trait for common filtering patterns:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
trait FiltersQueries
|
||||||
|
{
|
||||||
|
protected function applyFilters(Builder $query, Request $request): Builder
|
||||||
|
{
|
||||||
|
// Date filters
|
||||||
|
if ($after = $request->input('created_after')) {
|
||||||
|
$query->where('created_at', '>=', $after);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($before = $request->input('created_before')) {
|
||||||
|
$query->where('created_at', '<=', $before);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updatedAfter = $request->input('updated_after')) {
|
||||||
|
$query->where('updated_at', '>=', $updatedAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter (if model has status)
|
||||||
|
if ($status = $request->input('status')) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sorting
|
||||||
|
|
||||||
|
### Sort Parameter
|
||||||
|
|
||||||
|
Implement sorting with a `sort` query parameter:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = Post::query();
|
||||||
|
|
||||||
|
// Parse sort parameter: -created_at,title
|
||||||
|
$sortFields = $this->parseSortFields(
|
||||||
|
$request->input('sort', '-created_at')
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($sortFields as $field => $direction) {
|
||||||
|
$query->orderBy($field, $direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PaginatedCollection(
|
||||||
|
$query->paginate($request->input('per_page', 25)),
|
||||||
|
PostResource::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function parseSortFields(string $sort): array
|
||||||
|
{
|
||||||
|
$allowedFields = ['id', 'title', 'created_at', 'updated_at', 'published_at'];
|
||||||
|
$fields = [];
|
||||||
|
|
||||||
|
foreach (explode(',', $sort) as $field) {
|
||||||
|
$direction = 'asc';
|
||||||
|
|
||||||
|
if (str_starts_with($field, '-')) {
|
||||||
|
$direction = 'desc';
|
||||||
|
$field = substr($field, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($field, $allowedFields)) {
|
||||||
|
$fields[$field] = $direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields ?: ['created_at' => 'desc'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sort Validation
|
||||||
|
|
||||||
|
Validate sort fields against an allowlist:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'sort' => [
|
||||||
|
'string',
|
||||||
|
'regex:/^-?(id|title|created_at|updated_at)(,-?(id|title|created_at|updated_at))*$/',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Apply sorting...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Protecting Routes
|
||||||
|
|
||||||
|
Use the `auth:api` middleware to protect endpoints:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In your Boot class
|
||||||
|
$event->routes(function () {
|
||||||
|
// Public routes (no authentication)
|
||||||
|
Route::get('/posts', [PostController::class, 'index']);
|
||||||
|
Route::get('/posts/{post}', [PostController::class, 'show']);
|
||||||
|
|
||||||
|
// Protected routes (require authentication)
|
||||||
|
Route::middleware('auth:api')->group(function () {
|
||||||
|
Route::post('/posts', [PostController::class, 'store']);
|
||||||
|
Route::put('/posts/{post}', [PostController::class, 'update']);
|
||||||
|
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scope-Based Authorization
|
||||||
|
|
||||||
|
Enforce API key scopes on routes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::middleware(['auth:api', 'scope:posts:write'])
|
||||||
|
->post('/posts', [PostController::class, 'store']);
|
||||||
|
|
||||||
|
Route::middleware(['auth:api', 'scope:posts:delete'])
|
||||||
|
->delete('/posts/{post}', [PostController::class, 'destroy']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Scopes in Controllers
|
||||||
|
|
||||||
|
Verify scopes programmatically for fine-grained control:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function update(Request $request, Post $post)
|
||||||
|
{
|
||||||
|
// Check if user can update posts
|
||||||
|
if (!$request->user()->tokenCan('posts:write')) {
|
||||||
|
return $this->accessDeniedResponse('Insufficient permissions to update posts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can publish (requires elevated scope)
|
||||||
|
if ($request->input('status') === 'published') {
|
||||||
|
if (!$request->user()->tokenCan('posts:publish')) {
|
||||||
|
return $this->accessDeniedResponse('Insufficient permissions to publish posts.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->update($request->validated());
|
||||||
|
|
||||||
|
return new PostResource($post);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Key Authentication Examples
|
||||||
|
|
||||||
|
**PHP with Guzzle:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
|
||||||
|
$client = new Client([
|
||||||
|
'base_uri' => 'https://api.example.com/v1/',
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer ' . $apiKey,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// List posts
|
||||||
|
$response = $client->get('posts', [
|
||||||
|
'query' => [
|
||||||
|
'status' => 'published',
|
||||||
|
'per_page' => 50,
|
||||||
|
'sort' => '-published_at',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$posts = json_decode($response->getBody(), true);
|
||||||
|
|
||||||
|
// Create a post
|
||||||
|
$response = $client->post('posts', [
|
||||||
|
'json' => [
|
||||||
|
'title' => 'New Post',
|
||||||
|
'content' => 'Post content here...',
|
||||||
|
'status' => 'draft',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newPost = json_decode($response->getBody(), true);
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript with Fetch:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_KEY = 'sk_live_abc123...';
|
||||||
|
const BASE_URL = 'https://api.example.com/v1';
|
||||||
|
|
||||||
|
async function listPosts(params = {}) {
|
||||||
|
const query = new URLSearchParams(params).toString();
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/posts?${query}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPost(data) {
|
||||||
|
const response = await fetch(`${BASE_URL}/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || 'Failed to create post');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const posts = await listPosts({ status: 'published', per_page: 25 });
|
||||||
|
const newPost = await createPost({ title: 'Hello', content: 'World' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python with Requests:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API_KEY = 'sk_live_abc123...'
|
||||||
|
BASE_URL = 'https://api.example.com/v1'
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {API_KEY}',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
# List posts
|
||||||
|
response = requests.get(
|
||||||
|
f'{BASE_URL}/posts',
|
||||||
|
headers=headers,
|
||||||
|
params={
|
||||||
|
'status': 'published',
|
||||||
|
'per_page': 50,
|
||||||
|
'sort': '-published_at',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
posts = response.json()
|
||||||
|
|
||||||
|
# Create a post
|
||||||
|
response = requests.post(
|
||||||
|
f'{BASE_URL}/posts',
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
'title': 'New Post',
|
||||||
|
'content': 'Post content here...',
|
||||||
|
'status': 'draft',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
new_post = response.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAPI Documentation
|
||||||
|
|
||||||
|
### Document Endpoints
|
||||||
|
|
||||||
|
Use attributes to auto-generate OpenAPI documentation:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiTag;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiParameter;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiResponse;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
|
||||||
|
|
||||||
|
#[ApiTag('Posts', 'Blog post management')]
|
||||||
|
#[ApiSecurity('api_key')]
|
||||||
|
class PostController extends Controller
|
||||||
|
{
|
||||||
|
#[ApiParameter('page', 'query', 'integer', 'Page number', example: 1)]
|
||||||
|
#[ApiParameter('per_page', 'query', 'integer', 'Items per page', example: 25)]
|
||||||
|
#[ApiParameter('status', 'query', 'string', 'Filter by status', enum: ['draft', 'published'])]
|
||||||
|
#[ApiParameter('sort', 'query', 'string', 'Sort fields (prefix with - for desc)', example: '-created_at')]
|
||||||
|
#[ApiResponse(200, PostResource::class, 'List of posts', paginated: true)]
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ApiParameter('id', 'path', 'integer', 'Post ID', required: true)]
|
||||||
|
#[ApiResponse(200, PostResource::class, 'Post details')]
|
||||||
|
#[ApiResponse(404, null, 'Post not found')]
|
||||||
|
public function show(Post $post)
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ApiResponse(201, PostResource::class, 'Post created')]
|
||||||
|
#[ApiResponse(422, null, 'Validation error')]
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Consistent Error Responses
|
||||||
|
|
||||||
|
Use the `HasApiResponses` trait for consistent errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Api\Concerns\HasApiResponses;
|
||||||
|
|
||||||
|
class PostController extends Controller
|
||||||
|
{
|
||||||
|
use HasApiResponses;
|
||||||
|
|
||||||
|
public function show($id)
|
||||||
|
{
|
||||||
|
$post = Post::find($id);
|
||||||
|
|
||||||
|
if (!$post) {
|
||||||
|
return $this->notFoundResponse('Post');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PostResource($post);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
// Check entitlement limits
|
||||||
|
if (!$this->canCreatePost($request->user())) {
|
||||||
|
return $this->limitReachedResponse(
|
||||||
|
'posts',
|
||||||
|
'You have reached your post limit. Please upgrade your plan.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation errors are handled automatically by Laravel
|
||||||
|
$validated = $request->validate([...]);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response Format
|
||||||
|
|
||||||
|
All errors follow a consistent format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "not_found",
|
||||||
|
"message": "Post not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "validation_failed",
|
||||||
|
"message": "The given data was invalid.",
|
||||||
|
"errors": {
|
||||||
|
"title": ["The title field is required."],
|
||||||
|
"content": ["The content must be at least 100 characters."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "feature_limit_reached",
|
||||||
|
"message": "You have reached your post limit.",
|
||||||
|
"feature": "posts",
|
||||||
|
"upgrade_url": "https://example.com/upgrade"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use API Resources
|
||||||
|
|
||||||
|
Always transform models through resources:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - consistent response format
|
||||||
|
return new PostResource($post);
|
||||||
|
|
||||||
|
// Bad - exposes database schema
|
||||||
|
return response()->json($post);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Validate All Input
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'content' => 'required|string|min:100',
|
||||||
|
'status' => 'in:draft,published',
|
||||||
|
'published_at' => 'nullable|date|after:now',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Use validated data only
|
||||||
|
$post = Post::create($validated);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Eager Load Relationships
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - single query with eager loading
|
||||||
|
$posts = Post::with(['author', 'categories'])->paginate();
|
||||||
|
|
||||||
|
// Bad - N+1 queries
|
||||||
|
$posts = Post::paginate();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->author->name; // Additional query per post
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Route Model Binding
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good - automatic 404 if not found
|
||||||
|
public function show(Post $post)
|
||||||
|
{
|
||||||
|
return new PostResource($post);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unnecessary - route model binding handles this
|
||||||
|
public function show($id)
|
||||||
|
{
|
||||||
|
$post = Post::findOrFail($id);
|
||||||
|
return new PostResource($post);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Scope Data by Workspace
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$workspaceId = $request->user()->currentWorkspaceId();
|
||||||
|
|
||||||
|
$posts = Post::where('workspace_id', $workspaceId)
|
||||||
|
->paginate();
|
||||||
|
|
||||||
|
return new PaginatedCollection($posts, PostResource::class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Feature Tests
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
|
||||||
|
class PostApiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_lists_posts(): void
|
||||||
|
{
|
||||||
|
$apiKey = ApiKey::factory()->create([
|
||||||
|
'scopes' => ['posts:read'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Post::factory()->count(5)->create();
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => "Bearer {$apiKey->plaintext_key}",
|
||||||
|
])->getJson('/api/v1/posts');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'*' => ['id', 'type', 'attributes'],
|
||||||
|
],
|
||||||
|
'meta' => ['current_page', 'total'],
|
||||||
|
'links',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_filters_posts_by_status(): void
|
||||||
|
{
|
||||||
|
$apiKey = ApiKey::factory()->create(['scopes' => ['posts:read']]);
|
||||||
|
|
||||||
|
Post::factory()->create(['status' => 'draft']);
|
||||||
|
Post::factory()->create(['status' => 'published']);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => "Bearer {$apiKey->plaintext_key}",
|
||||||
|
])->getJson('/api/v1/posts?status=published');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonCount(1, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_post_with_valid_scope(): void
|
||||||
|
{
|
||||||
|
$apiKey = ApiKey::factory()->create([
|
||||||
|
'scopes' => ['posts:write'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => "Bearer {$apiKey->plaintext_key}",
|
||||||
|
])->postJson('/api/v1/posts', [
|
||||||
|
'title' => 'Test Post',
|
||||||
|
'content' => 'Test content...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated()
|
||||||
|
->assertJsonPath('data.attributes.title', 'Test Post');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rejects_create_without_scope(): void
|
||||||
|
{
|
||||||
|
$apiKey = ApiKey::factory()->create([
|
||||||
|
'scopes' => ['posts:read'], // No write scope
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => "Bearer {$apiKey->plaintext_key}",
|
||||||
|
])->postJson('/api/v1/posts', [
|
||||||
|
'title' => 'Test Post',
|
||||||
|
'content' => 'Test content...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Authentication](/packages/api/authentication) - API key management
|
||||||
|
- [Rate Limiting](/packages/api/rate-limiting) - Tier-based rate limits
|
||||||
|
- [Scopes](/packages/api/scopes) - Permission system
|
||||||
|
- [Webhooks](/packages/api/webhooks) - Event notifications
|
||||||
|
- [OpenAPI Documentation](/packages/api/documentation) - Auto-generated docs
|
||||||
1129
docs/packages/api/endpoints-reference.md
Normal file
1129
docs/packages/api/endpoints-reference.md
Normal file
File diff suppressed because it is too large
Load diff
765
docs/packages/api/webhook-integration.md
Normal file
765
docs/packages/api/webhook-integration.md
Normal file
|
|
@ -0,0 +1,765 @@
|
||||||
|
# Webhook Integration Guide
|
||||||
|
|
||||||
|
This guide explains how to receive and process webhooks from the core-api package. Learn to verify signatures, handle retries, and implement reliable webhook consumers.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Webhooks provide real-time notifications when events occur in the system. Instead of polling the API, your application receives HTTP POST requests with event data.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- HMAC-SHA256 signature verification
|
||||||
|
- Automatic retries with exponential backoff
|
||||||
|
- Timestamp validation for replay protection
|
||||||
|
- Delivery tracking and manual retry
|
||||||
|
|
||||||
|
## Webhook Payload Format
|
||||||
|
|
||||||
|
All webhooks follow a consistent format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "evt_abc123def456789",
|
||||||
|
"type": "post.created",
|
||||||
|
"created_at": "2026-01-15T10:30:00Z",
|
||||||
|
"data": {
|
||||||
|
"id": 123,
|
||||||
|
"title": "New Blog Post",
|
||||||
|
"status": "published",
|
||||||
|
"author_id": 42
|
||||||
|
},
|
||||||
|
"workspace_id": 456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
- `id` - Unique event identifier (use for idempotency)
|
||||||
|
- `type` - Event type (e.g., `post.created`, `user.updated`)
|
||||||
|
- `created_at` - ISO 8601 timestamp when the event occurred
|
||||||
|
- `data` - Event-specific payload
|
||||||
|
- `workspace_id` - Workspace that generated the event
|
||||||
|
|
||||||
|
## Webhook Headers
|
||||||
|
|
||||||
|
Every webhook request includes these headers:
|
||||||
|
|
||||||
|
| Header | Description | Example |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `Content-Type` | Always `application/json` | `application/json` |
|
||||||
|
| `X-Webhook-Id` | Unique event ID | `evt_abc123def456` |
|
||||||
|
| `X-Webhook-Event` | Event type | `post.created` |
|
||||||
|
| `X-Webhook-Timestamp` | Unix timestamp | `1705312200` |
|
||||||
|
| `X-Webhook-Signature` | HMAC-SHA256 signature | `a1b2c3d4e5f6...` |
|
||||||
|
|
||||||
|
## Signature Verification
|
||||||
|
|
||||||
|
**Always verify webhook signatures** to ensure requests are authentic and unmodified.
|
||||||
|
|
||||||
|
### Signature Algorithm
|
||||||
|
|
||||||
|
The signature is computed as:
|
||||||
|
|
||||||
|
```
|
||||||
|
signature = HMAC-SHA256(timestamp + "." + payload, secret)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `timestamp` is the value of `X-Webhook-Timestamp` header
|
||||||
|
- `payload` is the raw request body (JSON string)
|
||||||
|
- `secret` is your webhook signing secret
|
||||||
|
|
||||||
|
### Verification Steps
|
||||||
|
|
||||||
|
1. Get the signature and timestamp from headers
|
||||||
|
2. Get the raw request body (do not parse JSON first)
|
||||||
|
3. Compute expected signature: `HMAC-SHA256(timestamp + "." + body, secret)`
|
||||||
|
4. Compare signatures using timing-safe comparison
|
||||||
|
5. Verify timestamp is within 5 minutes of current time
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### PHP (Laravel)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class WebhookController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle incoming webhooks.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request)
|
||||||
|
{
|
||||||
|
// Step 1: Verify the signature
|
||||||
|
if (!$this->verifySignature($request)) {
|
||||||
|
Log::warning('Invalid webhook signature', [
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
return response()->json(['error' => 'Invalid signature'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Verify timestamp (replay protection)
|
||||||
|
if (!$this->verifyTimestamp($request)) {
|
||||||
|
Log::warning('Webhook timestamp too old');
|
||||||
|
return response()->json(['error' => 'Timestamp expired'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check for duplicate events (idempotency)
|
||||||
|
$eventId = $request->input('id');
|
||||||
|
if ($this->isDuplicate($eventId)) {
|
||||||
|
// Already processed - return success to stop retries
|
||||||
|
return response()->json(['received' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Process the event
|
||||||
|
try {
|
||||||
|
$this->processEvent(
|
||||||
|
$request->input('type'),
|
||||||
|
$request->input('data')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark event as processed
|
||||||
|
$this->markProcessed($eventId);
|
||||||
|
|
||||||
|
return response()->json(['received' => true]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Webhook processing failed', [
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return 500 to trigger retry
|
||||||
|
return response()->json(['error' => 'Processing failed'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the HMAC-SHA256 signature.
|
||||||
|
*/
|
||||||
|
protected function verifySignature(Request $request): bool
|
||||||
|
{
|
||||||
|
$signature = $request->header('X-Webhook-Signature');
|
||||||
|
$timestamp = $request->header('X-Webhook-Timestamp');
|
||||||
|
$payload = $request->getContent();
|
||||||
|
$secret = config('services.webhooks.secret');
|
||||||
|
|
||||||
|
if (!$signature || !$timestamp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute expected signature
|
||||||
|
$signedPayload = $timestamp . '.' . $payload;
|
||||||
|
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
|
||||||
|
|
||||||
|
// Use timing-safe comparison
|
||||||
|
return hash_equals($expectedSignature, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify timestamp is within tolerance (5 minutes).
|
||||||
|
*/
|
||||||
|
protected function verifyTimestamp(Request $request): bool
|
||||||
|
{
|
||||||
|
$timestamp = (int) $request->header('X-Webhook-Timestamp');
|
||||||
|
$tolerance = 300; // 5 minutes
|
||||||
|
|
||||||
|
return abs(time() - $timestamp) <= $tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event was already processed.
|
||||||
|
*/
|
||||||
|
protected function isDuplicate(string $eventId): bool
|
||||||
|
{
|
||||||
|
return cache()->has("webhook:processed:{$eventId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark event as processed (cache for 24 hours).
|
||||||
|
*/
|
||||||
|
protected function markProcessed(string $eventId): void
|
||||||
|
{
|
||||||
|
cache()->put("webhook:processed:{$eventId}", true, now()->addDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the webhook event.
|
||||||
|
*/
|
||||||
|
protected function processEvent(string $type, array $data): void
|
||||||
|
{
|
||||||
|
match ($type) {
|
||||||
|
'post.created' => $this->handlePostCreated($data),
|
||||||
|
'post.updated' => $this->handlePostUpdated($data),
|
||||||
|
'post.deleted' => $this->handlePostDeleted($data),
|
||||||
|
'user.created' => $this->handleUserCreated($data),
|
||||||
|
default => Log::info("Unhandled webhook type: {$type}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handlePostCreated(array $data): void
|
||||||
|
{
|
||||||
|
// Sync to your database, trigger notifications, etc.
|
||||||
|
Log::info('Post created', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handlePostUpdated(array $data): void
|
||||||
|
{
|
||||||
|
Log::info('Post updated', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handlePostDeleted(array $data): void
|
||||||
|
{
|
||||||
|
Log::info('Post deleted', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function handleUserCreated(array $data): void
|
||||||
|
{
|
||||||
|
Log::info('User created', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Route registration:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// routes/api.php
|
||||||
|
Route::post('/webhooks', [WebhookController::class, 'handle'])
|
||||||
|
->middleware('throttle:100,1'); // Rate limit webhook endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript (Node.js/Express)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Important: Use raw body for signature verification
|
||||||
|
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
|
||||||
|
const signature = req.headers['x-webhook-signature'];
|
||||||
|
const timestamp = req.headers['x-webhook-timestamp'];
|
||||||
|
const payload = req.body; // Raw buffer
|
||||||
|
const secret = process.env.WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
// Step 1: Verify signature
|
||||||
|
if (!verifySignature(payload, signature, timestamp, secret)) {
|
||||||
|
console.warn('Invalid webhook signature');
|
||||||
|
return res.status(401).json({ error: 'Invalid signature' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Verify timestamp
|
||||||
|
if (!verifyTimestamp(timestamp)) {
|
||||||
|
console.warn('Webhook timestamp too old');
|
||||||
|
return res.status(401).json({ error: 'Timestamp expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Parse the event
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(payload.toString());
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ error: 'Invalid JSON' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Check for duplicates
|
||||||
|
if (await isDuplicate(event.id)) {
|
||||||
|
return res.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Process the event
|
||||||
|
try {
|
||||||
|
await processEvent(event.type, event.data);
|
||||||
|
await markProcessed(event.id);
|
||||||
|
res.json({ received: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Webhook processing failed:', e);
|
||||||
|
res.status(500).json({ error: 'Processing failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function verifySignature(payload, signature, timestamp, secret) {
|
||||||
|
if (!signature || !timestamp) return false;
|
||||||
|
|
||||||
|
const signedPayload = timestamp + '.' + payload.toString();
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac('sha256', secret)
|
||||||
|
.update(signedPayload)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
// Timing-safe comparison
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
Buffer.from(signature),
|
||||||
|
Buffer.from(expectedSignature)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyTimestamp(timestamp) {
|
||||||
|
const tolerance = 300; // 5 minutes
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return Math.abs(now - parseInt(timestamp)) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis-based duplicate detection
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const redis = new Redis();
|
||||||
|
|
||||||
|
async function isDuplicate(eventId) {
|
||||||
|
return await redis.exists(`webhook:processed:${eventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markProcessed(eventId) {
|
||||||
|
await redis.set(`webhook:processed:${eventId}`, '1', 'EX', 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processEvent(type, data) {
|
||||||
|
switch (type) {
|
||||||
|
case 'post.created':
|
||||||
|
console.log('Post created:', data);
|
||||||
|
break;
|
||||||
|
case 'post.updated':
|
||||||
|
console.log('Post updated:', data);
|
||||||
|
break;
|
||||||
|
case 'post.deleted':
|
||||||
|
console.log('Post deleted:', data);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`Unhandled event type: ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python (Flask)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from functools import wraps
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
import redis
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
cache = redis.Redis()
|
||||||
|
|
||||||
|
WEBHOOK_SECRET = 'your_webhook_secret'
|
||||||
|
TIMESTAMP_TOLERANCE = 300 # 5 minutes
|
||||||
|
|
||||||
|
def verify_webhook(f):
|
||||||
|
"""Decorator to verify webhook signatures."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
signature = request.headers.get('X-Webhook-Signature')
|
||||||
|
timestamp = request.headers.get('X-Webhook-Timestamp')
|
||||||
|
payload = request.get_data()
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
if not verify_signature(payload, signature, timestamp):
|
||||||
|
return jsonify({'error': 'Invalid signature'}), 401
|
||||||
|
|
||||||
|
# Verify timestamp
|
||||||
|
if not verify_timestamp(timestamp):
|
||||||
|
return jsonify({'error': 'Timestamp expired'}), 401
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
|
||||||
|
"""Verify the HMAC-SHA256 signature."""
|
||||||
|
if not signature or not timestamp:
|
||||||
|
return False
|
||||||
|
|
||||||
|
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
WEBHOOK_SECRET.encode('utf-8'),
|
||||||
|
signed_payload.encode('utf-8'),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
# Timing-safe comparison
|
||||||
|
return hmac.compare_digest(expected_signature, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_timestamp(timestamp: str) -> bool:
|
||||||
|
"""Verify timestamp is within tolerance."""
|
||||||
|
try:
|
||||||
|
ts = int(timestamp)
|
||||||
|
return abs(time.time() - ts) <= TIMESTAMP_TOLERANCE
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate(event_id: str) -> bool:
|
||||||
|
"""Check if event was already processed."""
|
||||||
|
return cache.exists(f"webhook:processed:{event_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def mark_processed(event_id: str) -> None:
|
||||||
|
"""Mark event as processed (24 hour TTL)."""
|
||||||
|
cache.setex(f"webhook:processed:{event_id}", 86400, "1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/webhooks', methods=['POST'])
|
||||||
|
@verify_webhook
|
||||||
|
def handle_webhook():
|
||||||
|
event = request.get_json()
|
||||||
|
event_id = event.get('id')
|
||||||
|
event_type = event.get('type')
|
||||||
|
data = event.get('data')
|
||||||
|
|
||||||
|
# Check for duplicates
|
||||||
|
if is_duplicate(event_id):
|
||||||
|
return jsonify({'received': True})
|
||||||
|
|
||||||
|
# Process the event
|
||||||
|
try:
|
||||||
|
process_event(event_type, data)
|
||||||
|
mark_processed(event_id)
|
||||||
|
return jsonify({'received': True})
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Webhook processing failed: {e}")
|
||||||
|
return jsonify({'error': 'Processing failed'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def process_event(event_type: str, data: dict) -> None:
|
||||||
|
"""Process webhook event based on type."""
|
||||||
|
handlers = {
|
||||||
|
'post.created': handle_post_created,
|
||||||
|
'post.updated': handle_post_updated,
|
||||||
|
'post.deleted': handle_post_deleted,
|
||||||
|
'user.created': handle_user_created,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler = handlers.get(event_type)
|
||||||
|
if handler:
|
||||||
|
handler(data)
|
||||||
|
else:
|
||||||
|
app.logger.info(f"Unhandled event type: {event_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_post_created(data: dict) -> None:
|
||||||
|
app.logger.info(f"Post created: {data}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_post_updated(data: dict) -> None:
|
||||||
|
app.logger.info(f"Post updated: {data}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_post_deleted(data: dict) -> None:
|
||||||
|
app.logger.info(f"Post deleted: {data}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_user_created(data: dict) -> None:
|
||||||
|
app.logger.info(f"User created: {data}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(port=3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retry Handling
|
||||||
|
|
||||||
|
### Retry Schedule
|
||||||
|
|
||||||
|
Failed webhook deliveries are automatically retried with exponential backoff:
|
||||||
|
|
||||||
|
| Attempt | Delay | Total Time |
|
||||||
|
|---------|-------|------------|
|
||||||
|
| 1 | Immediate | 0 |
|
||||||
|
| 2 | 1 minute | 1 minute |
|
||||||
|
| 3 | 5 minutes | 6 minutes |
|
||||||
|
| 4 | 30 minutes | 36 minutes |
|
||||||
|
| 5 | 2 hours | 2h 36m |
|
||||||
|
| 6 (final) | 24 hours | 26h 36m |
|
||||||
|
|
||||||
|
After 6 failed attempts, the delivery is marked as permanently failed.
|
||||||
|
|
||||||
|
### Triggering Retries
|
||||||
|
|
||||||
|
A delivery is retried when your endpoint returns:
|
||||||
|
- **5xx status codes** (server errors)
|
||||||
|
- **Connection timeouts** (30 second default)
|
||||||
|
- **Connection refused/failed**
|
||||||
|
|
||||||
|
A delivery is **not** retried when:
|
||||||
|
- **2xx status codes** (success)
|
||||||
|
- **4xx status codes** (client errors - your endpoint rejected it)
|
||||||
|
|
||||||
|
### Best Practices for Reliability
|
||||||
|
|
||||||
|
**1. Return 200 Quickly**
|
||||||
|
|
||||||
|
Process webhooks asynchronously to avoid timeouts:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request)
|
||||||
|
{
|
||||||
|
// Verify signature first
|
||||||
|
if (!$this->verifySignature($request)) {
|
||||||
|
return response()->json(['error' => 'Invalid signature'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue for async processing
|
||||||
|
ProcessWebhook::dispatch($request->all());
|
||||||
|
|
||||||
|
// Return immediately
|
||||||
|
return response()->json(['received' => true]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Handle Duplicates**
|
||||||
|
|
||||||
|
Webhooks may be delivered more than once. Always check the event ID:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request)
|
||||||
|
{
|
||||||
|
$eventId = $request->input('id');
|
||||||
|
|
||||||
|
// Atomic check-and-set
|
||||||
|
if (!Cache::add("webhook:{$eventId}", true, now()->addDay())) {
|
||||||
|
// Already processed
|
||||||
|
return response()->json(['received' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the event...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Return 4xx for Permanent Failures**
|
||||||
|
|
||||||
|
If your endpoint cannot process an event (invalid data, etc.), return 4xx to stop retries:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request)
|
||||||
|
{
|
||||||
|
$eventType = $request->input('type');
|
||||||
|
|
||||||
|
// Unknown event type - don't retry
|
||||||
|
if (!in_array($eventType, $this->supportedEvents)) {
|
||||||
|
return response()->json(['error' => 'Unknown event type'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Types
|
||||||
|
|
||||||
|
### Common Events
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `{resource}.created` | Resource was created |
|
||||||
|
| `{resource}.updated` | Resource was updated |
|
||||||
|
| `{resource}.deleted` | Resource was deleted |
|
||||||
|
| `{resource}.published` | Resource was published |
|
||||||
|
| `{resource}.archived` | Resource was archived |
|
||||||
|
|
||||||
|
### Wildcard Subscriptions
|
||||||
|
|
||||||
|
Subscribe to all events for a resource:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$webhook = WebhookEndpoint::create([
|
||||||
|
'url' => 'https://your-app.com/webhooks',
|
||||||
|
'events' => ['post.*'], // All post events
|
||||||
|
'secret' => 'whsec_' . Str::random(32),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Subscribe to all events:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$webhook = WebhookEndpoint::create([
|
||||||
|
'url' => 'https://your-app.com/webhooks',
|
||||||
|
'events' => ['*'], // All events
|
||||||
|
'secret' => 'whsec_' . Str::random(32),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### High-Volume Events
|
||||||
|
|
||||||
|
Some events are high-volume and opt-in only:
|
||||||
|
|
||||||
|
- `link.clicked` - Link click tracking
|
||||||
|
- `qrcode.scanned` - QR code scan tracking
|
||||||
|
|
||||||
|
These must be explicitly included in the `events` array.
|
||||||
|
|
||||||
|
## Testing Webhooks
|
||||||
|
|
||||||
|
### Test Endpoint
|
||||||
|
|
||||||
|
Use the test endpoint to verify your webhook handler:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.example.com/v1/webhooks/{webhook_id}/test \
|
||||||
|
-H "Authorization: Bearer sk_live_abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
This sends a test event to your endpoint.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
For local development, use a tunnel service:
|
||||||
|
|
||||||
|
**ngrok:**
|
||||||
|
```bash
|
||||||
|
ngrok http 3000
|
||||||
|
# Use the https URL as your webhook endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cloudflare Tunnel:**
|
||||||
|
```bash
|
||||||
|
cloudflared tunnel --url http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock Verification
|
||||||
|
|
||||||
|
Test signature verification in isolation:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// tests/Feature/WebhookTest.php
|
||||||
|
public function test_verifies_valid_signature(): void
|
||||||
|
{
|
||||||
|
$payload = json_encode([
|
||||||
|
'id' => 'evt_test123',
|
||||||
|
'type' => 'post.created',
|
||||||
|
'data' => ['id' => 1, 'title' => 'Test'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$timestamp = time();
|
||||||
|
$secret = 'test_secret';
|
||||||
|
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
|
||||||
|
|
||||||
|
config(['services.webhooks.secret' => $secret]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/webhooks', json_decode($payload, true), [
|
||||||
|
'X-Webhook-Signature' => $signature,
|
||||||
|
'X-Webhook-Timestamp' => $timestamp,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rejects_invalid_signature(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/webhooks', [
|
||||||
|
'id' => 'evt_test123',
|
||||||
|
'type' => 'post.created',
|
||||||
|
], [
|
||||||
|
'X-Webhook-Signature' => 'invalid',
|
||||||
|
'X-Webhook-Timestamp' => time(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Signature Verification Fails
|
||||||
|
|
||||||
|
**Common causes:**
|
||||||
|
|
||||||
|
1. **Parsed JSON instead of raw body**
|
||||||
|
```php
|
||||||
|
// Wrong - body has been modified
|
||||||
|
$payload = json_encode($request->all());
|
||||||
|
|
||||||
|
// Correct - raw body
|
||||||
|
$payload = $request->getContent();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Different secrets**
|
||||||
|
- Check the secret matches exactly
|
||||||
|
- Ensure no extra whitespace
|
||||||
|
|
||||||
|
3. **Encoding issues**
|
||||||
|
```php
|
||||||
|
// Ensure UTF-8 encoding
|
||||||
|
$payload = $request->getContent();
|
||||||
|
$signedPayload = $timestamp . '.' . $payload;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deliveries Not Arriving
|
||||||
|
|
||||||
|
1. **Check endpoint URL** - Must be publicly accessible (not localhost)
|
||||||
|
2. **Check SSL certificate** - Must be valid and not expired
|
||||||
|
3. **Check firewall rules** - Allow incoming HTTPS from webhook IPs
|
||||||
|
4. **Check webhook is active** - Endpoints can be disabled after failures
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
The default timeout is 30 seconds. If processing takes longer:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Queue long-running tasks
|
||||||
|
public function handle(Request $request)
|
||||||
|
{
|
||||||
|
// Quick signature check
|
||||||
|
if (!$this->verifySignature($request)) {
|
||||||
|
return response()->json(['error' => 'Invalid signature'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue for async processing
|
||||||
|
ProcessWebhook::dispatch($request->all());
|
||||||
|
|
||||||
|
// Return immediately
|
||||||
|
return response()->json(['received' => true]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Always Verify Signatures
|
||||||
|
|
||||||
|
Never skip signature verification, even in development:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// DON'T DO THIS
|
||||||
|
if (app()->environment('local')) {
|
||||||
|
return; // Skip verification
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use HTTPS
|
||||||
|
|
||||||
|
Webhook endpoints must use HTTPS to protect:
|
||||||
|
- The webhook secret in transit
|
||||||
|
- Sensitive payload data
|
||||||
|
|
||||||
|
### Protect Your Secret
|
||||||
|
|
||||||
|
- Store in environment variables, not code
|
||||||
|
- Rotate secrets periodically
|
||||||
|
- Use different secrets per environment
|
||||||
|
|
||||||
|
### Rate Limit Your Endpoint
|
||||||
|
|
||||||
|
Protect against abuse:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::post('/webhooks', [WebhookController::class, 'handle'])
|
||||||
|
->middleware('throttle:100,1'); // 100 requests per minute
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Webhooks Overview](/packages/api/webhooks) - Creating webhook endpoints
|
||||||
|
- [Authentication](/packages/api/authentication) - API key management
|
||||||
|
- [Rate Limiting](/packages/api/rate-limiting) - Understanding rate limits
|
||||||
613
docs/packages/core/seeder-system.md
Normal file
613
docs/packages/core/seeder-system.md
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
# Seeder System
|
||||||
|
|
||||||
|
The Seeder System provides automatic discovery, dependency resolution, and ordered execution of database seeders across modules. It supports both auto-discovery and manual registration with explicit priority and dependency declarations.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Core seeder system offers:
|
||||||
|
|
||||||
|
- **Auto-discovery** - Finds seeders in module directories automatically
|
||||||
|
- **Dependency ordering** - Seeders run in dependency-resolved order
|
||||||
|
- **Priority control** - Fine-grained control over execution order
|
||||||
|
- **Circular detection** - Catches and reports circular dependencies
|
||||||
|
- **Filtering** - Include/exclude seeders at runtime
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `SeederDiscovery` | Auto-discovers and orders seeders |
|
||||||
|
| `SeederRegistry` | Manual seeder registration |
|
||||||
|
| `CoreDatabaseSeeder` | Base seeder with discovery support |
|
||||||
|
| `#[SeederPriority]` | Attribute for priority |
|
||||||
|
| `#[SeederAfter]` | Attribute for dependencies |
|
||||||
|
| `#[SeederBefore]` | Attribute for reverse dependencies |
|
||||||
|
| `CircularDependencyException` | Thrown on circular deps |
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
Seeders are auto-discovered in `Database/Seeders/` directories within configured module paths.
|
||||||
|
|
||||||
|
### Discovery Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
{module_path}/*/Database/Seeders/*Seeder.php
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, with module paths `[app_path('Mod')]`:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Mod/Blog/Database/Seeders/PostSeeder.php // Discovered
|
||||||
|
app/Mod/Blog/Database/Seeders/CategorySeeder.php // Discovered
|
||||||
|
app/Mod/Auth/Database/Seeders/UserSeeder.php // Discovered
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using SeederDiscovery
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Database\Seeders\SeederDiscovery;
|
||||||
|
|
||||||
|
$discovery = new SeederDiscovery([
|
||||||
|
app_path('Core'),
|
||||||
|
app_path('Mod'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get ordered seeders
|
||||||
|
$seeders = $discovery->discover();
|
||||||
|
// Returns: ['UserSeeder', 'CategorySeeder', 'PostSeeder', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Priority System
|
||||||
|
|
||||||
|
Seeders declare priority using the `#[SeederPriority]` attribute or a public `$priority` property. Lower priority values run first.
|
||||||
|
|
||||||
|
### Using the Attribute
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Database\Seeders\Attributes\SeederPriority;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
#[SeederPriority(10)]
|
||||||
|
class FeatureSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Runs early (priority 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[SeederPriority(90)]
|
||||||
|
class DemoDataSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Runs later (priority 90)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using a Property
|
||||||
|
|
||||||
|
```php
|
||||||
|
class FeatureSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public int $priority = 10;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Runs early
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priority Guidelines
|
||||||
|
|
||||||
|
| Range | Use Case | Examples |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| 0-20 | Foundation data | Features, configuration, settings |
|
||||||
|
| 20-40 | Core data | Packages, plans, workspaces |
|
||||||
|
| 40-60 | Default (50) | General module seeders |
|
||||||
|
| 60-80 | Content data | Pages, posts, products |
|
||||||
|
| 80-100 | Demo/test data | Sample content, test users |
|
||||||
|
|
||||||
|
## Dependency Resolution
|
||||||
|
|
||||||
|
Dependencies ensure seeders run in the correct order regardless of priority. Dependencies take precedence over priority.
|
||||||
|
|
||||||
|
### Using #[SeederAfter]
|
||||||
|
|
||||||
|
Declare that this seeder must run after specified seeders:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Database\Seeders\Attributes\SeederAfter;
|
||||||
|
use Mod\Feature\Database\Seeders\FeatureSeeder;
|
||||||
|
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
class PackageSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Runs after FeatureSeeder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Mod\Feature\Database\Seeders\FeatureSeeder;
|
||||||
|
use Mod\Tenant\Database\Seeders\TenantSeeder;
|
||||||
|
|
||||||
|
#[SeederAfter(FeatureSeeder::class, TenantSeeder::class)]
|
||||||
|
class WorkspaceSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Runs after both FeatureSeeder and TenantSeeder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using #[SeederBefore]
|
||||||
|
|
||||||
|
Declare that this seeder must run before specified seeders. This is the inverse relationship - you're saying other seeders depend on this one:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Database\Seeders\Attributes\SeederBefore;
|
||||||
|
use Mod\Package\Database\Seeders\PackageSeeder;
|
||||||
|
|
||||||
|
#[SeederBefore(PackageSeeder::class)]
|
||||||
|
class FeatureSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Runs before PackageSeeder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Properties
|
||||||
|
|
||||||
|
As an alternative to attributes, use public properties:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class WorkspaceSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public array $after = [
|
||||||
|
FeatureSeeder::class,
|
||||||
|
PackageSeeder::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public array $before = [
|
||||||
|
DemoSeeder::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Ordering Examples
|
||||||
|
|
||||||
|
### Example 1: Linear Chain
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Run order: Feature -> Package -> Workspace -> User
|
||||||
|
|
||||||
|
#[SeederPriority(10)]
|
||||||
|
class FeatureSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
class PackageSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(PackageSeeder::class)]
|
||||||
|
class WorkspaceSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(WorkspaceSeeder::class)]
|
||||||
|
class UserSeeder extends Seeder { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Diamond Dependency
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Feature
|
||||||
|
// / \
|
||||||
|
// Package Plan
|
||||||
|
// \ /
|
||||||
|
// Workspace
|
||||||
|
|
||||||
|
#[SeederPriority(10)]
|
||||||
|
class FeatureSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
class PackageSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
class PlanSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(PackageSeeder::class, PlanSeeder::class)]
|
||||||
|
class WorkspaceSeeder extends Seeder { }
|
||||||
|
|
||||||
|
// Execution order: Feature -> [Package, Plan] -> Workspace
|
||||||
|
// Package and Plan can run in either order (same priority level)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Priority with Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Dependencies override priority
|
||||||
|
|
||||||
|
#[SeederPriority(90)] // High priority number (normally runs late)
|
||||||
|
#[SeederBefore(DemoSeeder::class)]
|
||||||
|
class FeatureSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederPriority(10)] // Low priority number (normally runs early)
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
class DemoSeeder extends Seeder { }
|
||||||
|
|
||||||
|
// Despite priority, FeatureSeeder runs first due to dependency
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Mixed Priority and Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Seeders at the same dependency level sort by priority
|
||||||
|
|
||||||
|
#[SeederPriority(10)]
|
||||||
|
class FeatureSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
#[SeederPriority(20)] // Lower priority = runs first among siblings
|
||||||
|
class PackageSeeder extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
#[SeederPriority(30)] // Higher priority = runs after PackageSeeder
|
||||||
|
class PlanSeeder extends Seeder { }
|
||||||
|
|
||||||
|
// Order: Feature -> Package -> Plan
|
||||||
|
// (Package before Plan because 20 < 30)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Circular Dependency Errors
|
||||||
|
|
||||||
|
Circular dependencies are detected and throw `CircularDependencyException`.
|
||||||
|
|
||||||
|
### What Causes Circular Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
// This creates a cycle: A -> B -> C -> A
|
||||||
|
|
||||||
|
#[SeederAfter(SeederC::class)]
|
||||||
|
class SeederA extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(SeederA::class)]
|
||||||
|
class SeederB extends Seeder { }
|
||||||
|
|
||||||
|
#[SeederAfter(SeederB::class)]
|
||||||
|
class SeederC extends Seeder { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Database\Seeders\Exceptions\CircularDependencyException;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$seeders = $discovery->discover();
|
||||||
|
} catch (CircularDependencyException $e) {
|
||||||
|
echo $e->getMessage();
|
||||||
|
// "Circular dependency detected in seeders: SeederA -> SeederB -> SeederC -> SeederA"
|
||||||
|
|
||||||
|
// Get the cycle chain
|
||||||
|
$cycle = $e->cycle;
|
||||||
|
// ['SeederA', 'SeederB', 'SeederC', 'SeederA']
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging Circular Dependencies
|
||||||
|
|
||||||
|
1. Check the exception message for the cycle path
|
||||||
|
2. Review the `$after` and `$before` declarations
|
||||||
|
3. Remember that `#[SeederBefore]` creates implicit `after` relationships
|
||||||
|
4. Use the registry to inspect relationships:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$discovery = new SeederDiscovery([app_path('Mod')]);
|
||||||
|
$seeders = $discovery->getSeeders();
|
||||||
|
|
||||||
|
foreach ($seeders as $class => $meta) {
|
||||||
|
echo "{$class}:\n";
|
||||||
|
echo " Priority: {$meta['priority']}\n";
|
||||||
|
echo " After: " . implode(', ', $meta['after']) . "\n";
|
||||||
|
echo " Before: " . implode(', ', $meta['before']) . "\n";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Registration
|
||||||
|
|
||||||
|
Use `SeederRegistry` for explicit control over seeder ordering:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Database\Seeders\SeederRegistry;
|
||||||
|
|
||||||
|
$registry = new SeederRegistry();
|
||||||
|
|
||||||
|
// Register with options
|
||||||
|
$registry
|
||||||
|
->register(FeatureSeeder::class, priority: 10)
|
||||||
|
->register(PackageSeeder::class, after: [FeatureSeeder::class])
|
||||||
|
->register(WorkspaceSeeder::class, after: [PackageSeeder::class]);
|
||||||
|
|
||||||
|
// Get ordered list
|
||||||
|
$seeders = $registry->getOrdered();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bulk Registration
|
||||||
|
|
||||||
|
```php
|
||||||
|
$registry->registerMany([
|
||||||
|
FeatureSeeder::class => 10, // Priority shorthand
|
||||||
|
PackageSeeder::class => [
|
||||||
|
'priority' => 50,
|
||||||
|
'after' => [FeatureSeeder::class],
|
||||||
|
],
|
||||||
|
WorkspaceSeeder::class => [
|
||||||
|
'priority' => 50,
|
||||||
|
'after' => [PackageSeeder::class],
|
||||||
|
'before' => [DemoSeeder::class],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registry Operations
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Check if registered
|
||||||
|
$registry->has(FeatureSeeder::class);
|
||||||
|
|
||||||
|
// Remove a seeder
|
||||||
|
$registry->remove(DemoSeeder::class);
|
||||||
|
|
||||||
|
// Merge registries
|
||||||
|
$registry->merge($otherRegistry);
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
$registry->clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
## CoreDatabaseSeeder
|
||||||
|
|
||||||
|
Extend `CoreDatabaseSeeder` for automatic discovery in your application:
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Core\Database\Seeders\CoreDatabaseSeeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends CoreDatabaseSeeder
|
||||||
|
{
|
||||||
|
// Uses auto-discovery by default
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Paths
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DatabaseSeeder extends CoreDatabaseSeeder
|
||||||
|
{
|
||||||
|
protected function getSeederPaths(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
app_path('Core'),
|
||||||
|
app_path('Mod'),
|
||||||
|
base_path('packages/my-package/src'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Excluding Seeders
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DatabaseSeeder extends CoreDatabaseSeeder
|
||||||
|
{
|
||||||
|
protected function getExcludedSeeders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DemoDataSeeder::class,
|
||||||
|
TestUserSeeder::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disabling Auto-Discovery
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DatabaseSeeder extends CoreDatabaseSeeder
|
||||||
|
{
|
||||||
|
protected bool $autoDiscover = false;
|
||||||
|
|
||||||
|
protected function registerSeeders(SeederRegistry $registry): void
|
||||||
|
{
|
||||||
|
$registry
|
||||||
|
->register(FeatureSeeder::class, priority: 10)
|
||||||
|
->register(PackageSeeder::class, priority: 20)
|
||||||
|
->register(UserSeeder::class, priority: 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command-Line Filtering
|
||||||
|
|
||||||
|
Filter seeders when running `db:seed`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exclude specific seeders
|
||||||
|
php artisan db:seed --exclude=DemoSeeder
|
||||||
|
|
||||||
|
# Exclude multiple
|
||||||
|
php artisan db:seed --exclude=DemoSeeder --exclude=TestSeeder
|
||||||
|
|
||||||
|
# Run only specific seeders
|
||||||
|
php artisan db:seed --only=UserSeeder
|
||||||
|
|
||||||
|
# Run multiple specific seeders
|
||||||
|
php artisan db:seed --only=UserSeeder --only=FeatureSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern Matching
|
||||||
|
|
||||||
|
Filters support multiple matching strategies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full class name
|
||||||
|
php artisan db:seed --exclude=Mod\\Blog\\Database\\Seeders\\PostSeeder
|
||||||
|
|
||||||
|
# Short name
|
||||||
|
php artisan db:seed --exclude=PostSeeder
|
||||||
|
|
||||||
|
# Partial match
|
||||||
|
php artisan db:seed --exclude=Demo # Matches DemoSeeder, DemoDataSeeder, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure the seeder system in `config/core.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
'seeders' => [
|
||||||
|
// Enable auto-discovery
|
||||||
|
'auto_discover' => env('CORE_SEEDER_AUTODISCOVER', true),
|
||||||
|
|
||||||
|
// Paths to scan
|
||||||
|
'paths' => [
|
||||||
|
app_path('Core'),
|
||||||
|
app_path('Mod'),
|
||||||
|
app_path('Website'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Classes to exclude
|
||||||
|
'exclude' => [
|
||||||
|
// App\Mod\Demo\Database\Seeders\DemoSeeder::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Explicit Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Preferred: Explicit dependencies
|
||||||
|
#[SeederAfter(FeatureSeeder::class)]
|
||||||
|
class PackageSeeder extends Seeder { }
|
||||||
|
|
||||||
|
// Avoid: Relying only on priority for ordering
|
||||||
|
#[SeederPriority(51)] // Fragile - assumes FeatureSeeder is 50
|
||||||
|
class PackageSeeder extends Seeder { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Keep Seeders Focused
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Good: Single responsibility
|
||||||
|
class PostSeeder extends Seeder {
|
||||||
|
public function run(): void {
|
||||||
|
Post::factory()->count(50)->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid: Monolithic seeders
|
||||||
|
class EverythingSeeder extends Seeder {
|
||||||
|
public function run(): void {
|
||||||
|
// Creates users, posts, comments, categories, tags...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Factories in Seeders
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PostSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Good: Use factories for consistent test data
|
||||||
|
Post::factory()
|
||||||
|
->count(50)
|
||||||
|
->has(Comment::factory()->count(3))
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Idempotency
|
||||||
|
|
||||||
|
```php
|
||||||
|
class FeatureSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Good: Use updateOrCreate for idempotent seeding
|
||||||
|
Feature::updateOrCreate(
|
||||||
|
['code' => 'blog'],
|
||||||
|
['name' => 'Blog', 'enabled' => true]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Document Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Seeds packages for the tenant module.
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - FeatureSeeder: Features must exist to link packages
|
||||||
|
* - TenantSeeder: Tenants must exist to assign packages
|
||||||
|
*/
|
||||||
|
#[SeederAfter(FeatureSeeder::class, TenantSeeder::class)]
|
||||||
|
class PackageSeeder extends Seeder { }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Seeders Not Discovered
|
||||||
|
|
||||||
|
1. Check the file is in `Database/Seeders/` subdirectory
|
||||||
|
2. Verify class name ends with `Seeder`
|
||||||
|
3. Confirm namespace matches file location
|
||||||
|
4. Check the path is included in discovery paths
|
||||||
|
|
||||||
|
### Wrong Execution Order
|
||||||
|
|
||||||
|
1. Print discovery results to verify:
|
||||||
|
```php
|
||||||
|
$discovery = new SeederDiscovery([app_path('Mod')]);
|
||||||
|
dd($discovery->getSeeders());
|
||||||
|
```
|
||||||
|
2. Check for missing `#[SeederAfter]` declarations
|
||||||
|
3. Verify priority values (lower runs first)
|
||||||
|
|
||||||
|
### Circular Dependency Error
|
||||||
|
|
||||||
|
1. Read the error message for the cycle
|
||||||
|
2. Draw out the dependency graph
|
||||||
|
3. Identify which relationship should be removed/reversed
|
||||||
|
4. Consider if the circular dependency indicates a design issue
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Module System](/packages/core/modules)
|
||||||
|
- [Service Contracts](/packages/core/service-contracts)
|
||||||
|
- [Configuration](/packages/core/configuration)
|
||||||
510
docs/packages/core/service-contracts.md
Normal file
510
docs/packages/core/service-contracts.md
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
# Service Contracts
|
||||||
|
|
||||||
|
The Service Contracts system provides a structured way to define SaaS services as first-class citizens in the framework. Services are the product layer - they define how modules are presented to users as SaaS products.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Services in Core PHP are:
|
||||||
|
|
||||||
|
- **Discoverable** - Automatically found in configured module paths
|
||||||
|
- **Versioned** - Support semantic versioning with deprecation tracking
|
||||||
|
- **Dependency-aware** - Declare and validate dependencies on other services
|
||||||
|
- **Health-monitored** - Optional health checks for operational status
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `ServiceDefinition` | Interface for defining a service |
|
||||||
|
| `ServiceDiscovery` | Discovers and resolves services |
|
||||||
|
| `ServiceVersion` | Semantic versioning with deprecation |
|
||||||
|
| `ServiceDependency` | Declares service dependencies |
|
||||||
|
| `HealthCheckable` | Optional health monitoring |
|
||||||
|
| `HasServiceVersion` | Trait with default implementations |
|
||||||
|
|
||||||
|
## Creating a Service
|
||||||
|
|
||||||
|
### Basic Service Definition
|
||||||
|
|
||||||
|
Implement the `ServiceDefinition` interface to create a service:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Billing;
|
||||||
|
|
||||||
|
use Core\Service\Contracts\ServiceDefinition;
|
||||||
|
use Core\Service\Contracts\ServiceDependency;
|
||||||
|
use Core\Service\Concerns\HasServiceVersion;
|
||||||
|
use Core\Service\ServiceVersion;
|
||||||
|
|
||||||
|
class BillingService implements ServiceDefinition
|
||||||
|
{
|
||||||
|
use HasServiceVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service metadata for the platform_services table.
|
||||||
|
*/
|
||||||
|
public static function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => 'billing', // Unique identifier
|
||||||
|
'module' => 'Mod\\Billing', // Module namespace
|
||||||
|
'name' => 'Billing Service', // Display name
|
||||||
|
'tagline' => 'Handle payments and invoices', // Short description
|
||||||
|
'description' => 'Complete billing solution with Stripe integration',
|
||||||
|
'icon' => 'credit-card', // FontAwesome icon
|
||||||
|
'color' => '#10B981', // Brand color (hex)
|
||||||
|
'entitlement_code' => 'core.srv.billing', // Access control
|
||||||
|
'sort_order' => 20, // Menu ordering
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare dependencies on other services.
|
||||||
|
*/
|
||||||
|
public static function dependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ServiceDependency::required('auth', '>=1.0.0'),
|
||||||
|
ServiceDependency::optional('analytics'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin menu items provided by this service.
|
||||||
|
*/
|
||||||
|
public function menuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'label' => 'Billing',
|
||||||
|
'icon' => 'credit-card',
|
||||||
|
'route' => 'admin.billing.index',
|
||||||
|
'order' => 20,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Definition Array Fields
|
||||||
|
|
||||||
|
| Field | Required | Type | Description |
|
||||||
|
|-------|----------|------|-------------|
|
||||||
|
| `code` | Yes | string | Unique service identifier (lowercase, alphanumeric) |
|
||||||
|
| `module` | Yes | string | Module namespace |
|
||||||
|
| `name` | Yes | string | Display name |
|
||||||
|
| `tagline` | No | string | Short description |
|
||||||
|
| `description` | No | string | Full description |
|
||||||
|
| `icon` | No | string | FontAwesome icon name |
|
||||||
|
| `color` | No | string | Hex color (e.g., `#3B82F6`) |
|
||||||
|
| `entitlement_code` | No | string | Access control entitlement |
|
||||||
|
| `sort_order` | No | int | Menu/display ordering |
|
||||||
|
|
||||||
|
## Service Versioning
|
||||||
|
|
||||||
|
Services use semantic versioning to track API compatibility and manage deprecation.
|
||||||
|
|
||||||
|
### Basic Versioning
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Service\ServiceVersion;
|
||||||
|
|
||||||
|
// Create version 2.1.0
|
||||||
|
$version = new ServiceVersion(2, 1, 0);
|
||||||
|
echo $version; // "2.1.0"
|
||||||
|
|
||||||
|
// Parse from string
|
||||||
|
$version = ServiceVersion::fromString('v2.1.0');
|
||||||
|
|
||||||
|
// Default version (1.0.0)
|
||||||
|
$version = ServiceVersion::initial();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Versioning Rules
|
||||||
|
|
||||||
|
| Change | Version Bump | Description |
|
||||||
|
|--------|--------------|-------------|
|
||||||
|
| Major | 1.0.0 -> 2.0.0 | Breaking changes to the service contract |
|
||||||
|
| Minor | 1.0.0 -> 1.1.0 | New features, backwards compatible |
|
||||||
|
| Patch | 1.0.0 -> 1.0.1 | Bug fixes, backwards compatible |
|
||||||
|
|
||||||
|
### Implementing Custom Versions
|
||||||
|
|
||||||
|
Override the `version()` method from the trait:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Service\ServiceVersion;
|
||||||
|
use Core\Service\Concerns\HasServiceVersion;
|
||||||
|
|
||||||
|
class MyService implements ServiceDefinition
|
||||||
|
{
|
||||||
|
use HasServiceVersion;
|
||||||
|
|
||||||
|
public static function version(): ServiceVersion
|
||||||
|
{
|
||||||
|
return new ServiceVersion(2, 3, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Deprecation
|
||||||
|
|
||||||
|
Mark services as deprecated with migration guidance:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static function version(): ServiceVersion
|
||||||
|
{
|
||||||
|
return (new ServiceVersion(1, 0, 0))
|
||||||
|
->deprecate(
|
||||||
|
'Migrate to BillingV2 - see docs/migration.md',
|
||||||
|
new \DateTimeImmutable('2026-06-01')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deprecation Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
[Active] ──deprecate()──> [Deprecated] ──isPastSunset()──> [Sunset]
|
||||||
|
```
|
||||||
|
|
||||||
|
| State | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| Active | Service fully operational |
|
||||||
|
| Deprecated | Works but logs warnings; consumers should migrate |
|
||||||
|
| Sunset | Past sunset date; may throw exceptions |
|
||||||
|
|
||||||
|
### Checking Deprecation Status
|
||||||
|
|
||||||
|
```php
|
||||||
|
$version = MyService::version();
|
||||||
|
|
||||||
|
// Check if deprecated
|
||||||
|
if ($version->deprecated) {
|
||||||
|
echo $version->deprecationMessage;
|
||||||
|
echo $version->sunsetDate->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if past sunset
|
||||||
|
if ($version->isPastSunset()) {
|
||||||
|
throw new ServiceSunsetException('This service is no longer available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version compatibility
|
||||||
|
$minimum = new ServiceVersion(1, 5, 0);
|
||||||
|
$current = new ServiceVersion(1, 8, 2);
|
||||||
|
$current->isCompatibleWith($minimum); // true (same major, >= minor.patch)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Resolution
|
||||||
|
|
||||||
|
Services can declare dependencies on other services, and the framework resolves them automatically.
|
||||||
|
|
||||||
|
### Declaring Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Service\Contracts\ServiceDependency;
|
||||||
|
|
||||||
|
public static function dependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Required dependency - service fails if not available
|
||||||
|
ServiceDependency::required('auth', '>=1.0.0'),
|
||||||
|
|
||||||
|
// Optional dependency - service works with reduced functionality
|
||||||
|
ServiceDependency::optional('analytics'),
|
||||||
|
|
||||||
|
// Version range constraints
|
||||||
|
ServiceDependency::required('billing', '>=2.0.0', '<3.0.0'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Constraints
|
||||||
|
|
||||||
|
| Constraint | Meaning |
|
||||||
|
|------------|---------|
|
||||||
|
| `>=1.0.0` | Minimum version 1.0.0 |
|
||||||
|
| `<3.0.0` | Maximum version below 3.0.0 |
|
||||||
|
| `>=2.0.0`, `<3.0.0` | Version 2.x only |
|
||||||
|
| `null` | Any version |
|
||||||
|
|
||||||
|
### Using ServiceDiscovery
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Service\ServiceDiscovery;
|
||||||
|
|
||||||
|
$discovery = app(ServiceDiscovery::class);
|
||||||
|
|
||||||
|
// Get all registered services
|
||||||
|
$services = $discovery->discover();
|
||||||
|
|
||||||
|
// Check if a service is available
|
||||||
|
if ($discovery->has('billing')) {
|
||||||
|
$billingClass = $discovery->get('billing');
|
||||||
|
$billing = $discovery->getInstance('billing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get services in dependency order
|
||||||
|
$ordered = $discovery->getResolutionOrder();
|
||||||
|
|
||||||
|
// Validate all dependencies
|
||||||
|
$missing = $discovery->validateDependencies();
|
||||||
|
if (!empty($missing)) {
|
||||||
|
foreach ($missing as $service => $deps) {
|
||||||
|
logger()->error("Service {$service} missing: " . implode(', ', $deps));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolution Order
|
||||||
|
|
||||||
|
The framework uses topological sorting to resolve services in the correct order:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Services are resolved so dependencies come first
|
||||||
|
$ordered = $discovery->getResolutionOrder();
|
||||||
|
// Returns: ['auth', 'analytics', 'billing']
|
||||||
|
// (auth before billing if billing depends on auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Circular Dependencies
|
||||||
|
|
||||||
|
Circular dependencies are detected and throw `ServiceDependencyException`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Service\ServiceDependencyException;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ordered = $discovery->getResolutionOrder();
|
||||||
|
} catch (ServiceDependencyException $e) {
|
||||||
|
// Circular dependency: auth -> billing -> auth
|
||||||
|
echo $e->getMessage();
|
||||||
|
print_r($e->getDependencyChain());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Service Registration
|
||||||
|
|
||||||
|
Register services programmatically when auto-discovery is not desired:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$discovery = app(ServiceDiscovery::class);
|
||||||
|
|
||||||
|
// Register with validation
|
||||||
|
$discovery->register(BillingService::class);
|
||||||
|
|
||||||
|
// Register without validation
|
||||||
|
$discovery->register(BillingService::class, validate: false);
|
||||||
|
|
||||||
|
// Add additional scan paths
|
||||||
|
$discovery->addPath(base_path('packages/my-package/src'));
|
||||||
|
|
||||||
|
// Clear discovery cache
|
||||||
|
$discovery->clearCache();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Monitoring
|
||||||
|
|
||||||
|
Services can implement health checks for operational monitoring.
|
||||||
|
|
||||||
|
### Implementing HealthCheckable
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Service\Contracts\ServiceDefinition;
|
||||||
|
use Core\Service\Contracts\HealthCheckable;
|
||||||
|
use Core\Service\HealthCheckResult;
|
||||||
|
|
||||||
|
class BillingService implements ServiceDefinition, HealthCheckable
|
||||||
|
{
|
||||||
|
// ... service definition methods ...
|
||||||
|
|
||||||
|
public function healthCheck(): HealthCheckResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
// Test critical dependencies
|
||||||
|
$stripeConnected = $this->stripe->testConnection();
|
||||||
|
|
||||||
|
$responseTime = (microtime(true) - $start) * 1000;
|
||||||
|
|
||||||
|
if (!$stripeConnected) {
|
||||||
|
return HealthCheckResult::unhealthy(
|
||||||
|
'Cannot connect to Stripe',
|
||||||
|
['stripe_status' => 'disconnected']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($responseTime > 1000) {
|
||||||
|
return HealthCheckResult::degraded(
|
||||||
|
'Stripe responding slowly',
|
||||||
|
['response_time_ms' => $responseTime],
|
||||||
|
responseTimeMs: $responseTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthCheckResult::healthy(
|
||||||
|
'All billing systems operational',
|
||||||
|
['stripe_status' => 'connected'],
|
||||||
|
responseTimeMs: $responseTime
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return HealthCheckResult::fromException($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check Result States
|
||||||
|
|
||||||
|
| Status | Method | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| Healthy | `HealthCheckResult::healthy()` | Fully operational |
|
||||||
|
| Degraded | `HealthCheckResult::degraded()` | Working with reduced performance |
|
||||||
|
| Unhealthy | `HealthCheckResult::unhealthy()` | Not operational |
|
||||||
|
| Unknown | `HealthCheckResult::unknown()` | Status cannot be determined |
|
||||||
|
|
||||||
|
### Health Check Guidelines
|
||||||
|
|
||||||
|
- **Fast** - Complete within 5 seconds (preferably < 1 second)
|
||||||
|
- **Non-destructive** - Read-only operations only
|
||||||
|
- **Representative** - Test actual critical dependencies
|
||||||
|
- **Safe** - Catch all exceptions, return HealthCheckResult
|
||||||
|
|
||||||
|
### Aggregating Health Checks
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Service\Enums\ServiceStatus;
|
||||||
|
|
||||||
|
// Get all health check results
|
||||||
|
$results = [];
|
||||||
|
foreach ($discovery->discover() as $code => $class) {
|
||||||
|
$instance = $discovery->getInstance($code);
|
||||||
|
|
||||||
|
if ($instance instanceof HealthCheckable) {
|
||||||
|
$results[$code] = $instance->healthCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine overall status
|
||||||
|
$statuses = array_map(fn($r) => $r->status, $results);
|
||||||
|
$overall = ServiceStatus::worst($statuses);
|
||||||
|
|
||||||
|
if (!$overall->isOperational()) {
|
||||||
|
// Alert on-call team
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here is a complete service implementation with all features:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Service\Contracts\ServiceDefinition;
|
||||||
|
use Core\Service\Contracts\ServiceDependency;
|
||||||
|
use Core\Service\Contracts\HealthCheckable;
|
||||||
|
use Core\Service\HealthCheckResult;
|
||||||
|
use Core\Service\ServiceVersion;
|
||||||
|
|
||||||
|
class BlogService implements ServiceDefinition, HealthCheckable
|
||||||
|
{
|
||||||
|
public static function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => 'blog',
|
||||||
|
'module' => 'Mod\\Blog',
|
||||||
|
'name' => 'Blog',
|
||||||
|
'tagline' => 'Content publishing platform',
|
||||||
|
'description' => 'Full-featured blog with categories, tags, and comments',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'color' => '#6366F1',
|
||||||
|
'entitlement_code' => 'core.srv.blog',
|
||||||
|
'sort_order' => 30,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function version(): ServiceVersion
|
||||||
|
{
|
||||||
|
return new ServiceVersion(2, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ServiceDependency::required('auth', '>=1.0.0'),
|
||||||
|
ServiceDependency::required('media', '>=1.0.0'),
|
||||||
|
ServiceDependency::optional('seo'),
|
||||||
|
ServiceDependency::optional('analytics'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function menuItems(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'label' => 'Blog',
|
||||||
|
'icon' => 'newspaper',
|
||||||
|
'route' => 'admin.blog.index',
|
||||||
|
'order' => 30,
|
||||||
|
'children' => [
|
||||||
|
['label' => 'Posts', 'route' => 'admin.blog.posts'],
|
||||||
|
['label' => 'Categories', 'route' => 'admin.blog.categories'],
|
||||||
|
['label' => 'Tags', 'route' => 'admin.blog.tags'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function healthCheck(): HealthCheckResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$postsTable = \DB::table('posts')->exists();
|
||||||
|
|
||||||
|
if (!$postsTable) {
|
||||||
|
return HealthCheckResult::unhealthy('Posts table not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthCheckResult::healthy('Blog service operational');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return HealthCheckResult::fromException($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure service discovery in `config/core.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
'services' => [
|
||||||
|
// Enable/disable discovery caching
|
||||||
|
'cache_discovery' => env('CORE_CACHE_SERVICES', true),
|
||||||
|
|
||||||
|
// Cache TTL in seconds (default: 1 hour)
|
||||||
|
'cache_ttl' => 3600,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Paths to scan for services
|
||||||
|
'module_paths' => [
|
||||||
|
app_path('Core'),
|
||||||
|
app_path('Mod'),
|
||||||
|
app_path('Website'),
|
||||||
|
app_path('Plug'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Module System](/packages/core/modules)
|
||||||
|
- [Lifecycle Events](/packages/core/events)
|
||||||
|
- [Seeder System](/packages/core/seeder-system)
|
||||||
787
docs/packages/mcp/creating-mcp-tools.md
Normal file
787
docs/packages/mcp/creating-mcp-tools.md
Normal file
|
|
@ -0,0 +1,787 @@
|
||||||
|
# Guide: Creating MCP Tools
|
||||||
|
|
||||||
|
This guide covers everything you need to create MCP tools for AI agents, from basic tools to advanced patterns with workspace context, dependencies, and security best practices.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MCP (Model Context Protocol) tools allow AI agents to interact with your application. Each tool:
|
||||||
|
|
||||||
|
- Has a unique name and description
|
||||||
|
- Defines input parameters with JSON Schema
|
||||||
|
- Executes logic and returns structured responses
|
||||||
|
- Can require workspace context for multi-tenant isolation
|
||||||
|
- Can declare dependencies on other tools
|
||||||
|
|
||||||
|
## Tool Interface
|
||||||
|
|
||||||
|
All MCP tools extend `Laravel\Mcp\Server\Tool` and implement two required methods:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Blog\Tools;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
|
||||||
|
class ListPostsTool extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'List all blog posts with optional filters';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
// Tool logic here
|
||||||
|
$posts = Post::limit(10)->get();
|
||||||
|
|
||||||
|
return Response::text(json_encode($posts->toArray(), JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $schema->string('Filter by post status'),
|
||||||
|
'limit' => $schema->integer('Maximum posts to return')->default(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Methods
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `$description` | Tool description shown to AI agents |
|
||||||
|
| `handle(Request)` | Execute the tool and return a Response |
|
||||||
|
| `schema(JsonSchema)` | Define input parameters |
|
||||||
|
|
||||||
|
## Parameter Validation
|
||||||
|
|
||||||
|
Define parameters using the `JsonSchema` builder in the `schema()` method:
|
||||||
|
|
||||||
|
### String Parameters
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Basic string
|
||||||
|
'title' => $schema->string('Post title')->required(),
|
||||||
|
|
||||||
|
// Enum values
|
||||||
|
'status' => $schema->string('Post status: draft, published, archived'),
|
||||||
|
|
||||||
|
// With default
|
||||||
|
'format' => $schema->string('Output format')->default('json'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Numeric Parameters
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Integer
|
||||||
|
'limit' => $schema->integer('Maximum results')->default(10),
|
||||||
|
|
||||||
|
// Number (float)
|
||||||
|
'price' => $schema->number('Product price'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Boolean Parameters
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'include_drafts' => $schema->boolean('Include draft posts')->default(false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array Parameters
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tags' => $schema->array('Filter by tags'),
|
||||||
|
'ids' => $schema->array('Specific post IDs to fetch'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required vs Optional
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Required - AI agent must provide this
|
||||||
|
'query' => $schema->string('SQL query to execute')->required(),
|
||||||
|
|
||||||
|
// Optional with default
|
||||||
|
'limit' => $schema->integer('Max rows')->default(100),
|
||||||
|
|
||||||
|
// Optional without default
|
||||||
|
'status' => $schema->string('Filter status'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Parameters
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
// Get single parameter
|
||||||
|
$query = $request->input('query');
|
||||||
|
|
||||||
|
// Get with default
|
||||||
|
$limit = $request->input('limit', 10);
|
||||||
|
|
||||||
|
// Check if parameter exists
|
||||||
|
if ($request->has('status')) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all parameters
|
||||||
|
$params = $request->all();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Validation
|
||||||
|
|
||||||
|
For validation beyond schema types, validate in `handle()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$email = $request->input('email');
|
||||||
|
|
||||||
|
// Custom validation
|
||||||
|
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'error' => 'Invalid email format',
|
||||||
|
'code' => 'VALIDATION_ERROR',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate limit range
|
||||||
|
$limit = $request->input('limit', 10);
|
||||||
|
if ($limit < 1 || $limit > 100) {
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'error' => 'Limit must be between 1 and 100',
|
||||||
|
'code' => 'VALIDATION_ERROR',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with tool logic...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace Context
|
||||||
|
|
||||||
|
For multi-tenant applications, tools must access data scoped to the authenticated workspace. **Never accept workspace ID as a user-supplied parameter** - this prevents cross-tenant data access.
|
||||||
|
|
||||||
|
### Using RequiresWorkspaceContext
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Blog\Tools;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||||
|
|
||||||
|
class ListWorkspacePostsTool extends Tool
|
||||||
|
{
|
||||||
|
use RequiresWorkspaceContext;
|
||||||
|
|
||||||
|
protected string $description = 'List posts in your workspace';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
// Get workspace from authenticated context (NOT from request params)
|
||||||
|
$workspace = $this->getWorkspace();
|
||||||
|
$workspaceId = $this->getWorkspaceId();
|
||||||
|
|
||||||
|
$posts = Post::where('workspace_id', $workspaceId)
|
||||||
|
->limit($request->input('limit', 10))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'workspace' => $workspace->name,
|
||||||
|
'posts' => $posts->toArray(),
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
// Note: No workspace_id parameter - comes from auth context
|
||||||
|
return [
|
||||||
|
'limit' => $schema->integer('Maximum posts to return'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trait Methods
|
||||||
|
|
||||||
|
The `RequiresWorkspaceContext` trait provides:
|
||||||
|
|
||||||
|
| Method | Returns | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `getWorkspaceContext()` | `WorkspaceContext` | Full context object |
|
||||||
|
| `getWorkspaceId()` | `int` | Workspace ID only |
|
||||||
|
| `getWorkspace()` | `Workspace` | Workspace model |
|
||||||
|
| `hasWorkspaceContext()` | `bool` | Check if context available |
|
||||||
|
| `validateResourceOwnership(int, string)` | `void` | Validate resource belongs to workspace |
|
||||||
|
|
||||||
|
### Setting Workspace Context
|
||||||
|
|
||||||
|
Workspace context is set by middleware from authentication (API key or user session):
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In middleware or controller
|
||||||
|
$tool = new ListWorkspacePostsTool();
|
||||||
|
$tool->setWorkspaceContext(WorkspaceContext::fromWorkspace($workspace));
|
||||||
|
|
||||||
|
// Or from ID
|
||||||
|
$tool->setWorkspaceId($workspaceId);
|
||||||
|
|
||||||
|
// Or from workspace model
|
||||||
|
$tool->setWorkspace($workspace);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validating Resource Ownership
|
||||||
|
|
||||||
|
When accessing specific resources, validate they belong to the workspace:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$postId = $request->input('post_id');
|
||||||
|
$post = Post::findOrFail($postId);
|
||||||
|
|
||||||
|
// Throws RuntimeException if post doesn't belong to workspace
|
||||||
|
$this->validateResourceOwnership($post->workspace_id, 'post');
|
||||||
|
|
||||||
|
// Safe to proceed
|
||||||
|
return Response::text(json_encode($post->toArray()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Dependencies
|
||||||
|
|
||||||
|
Tools can declare dependencies that must be satisfied before execution. This is useful for workflows where tools must be called in a specific order.
|
||||||
|
|
||||||
|
### Declaring Dependencies
|
||||||
|
|
||||||
|
Implement `HasDependencies` or use `ValidatesDependencies` trait:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Blog\Tools;
|
||||||
|
|
||||||
|
use Core\Mod\Mcp\Dependencies\DependencyType;
|
||||||
|
use Core\Mod\Mcp\Dependencies\HasDependencies;
|
||||||
|
use Core\Mod\Mcp\Dependencies\ToolDependency;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
|
||||||
|
class UpdateTaskTool extends Tool implements HasDependencies
|
||||||
|
{
|
||||||
|
protected string $description = 'Update a task in the current plan';
|
||||||
|
|
||||||
|
public function dependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Another tool must be called first
|
||||||
|
ToolDependency::toolCalled(
|
||||||
|
'plan_create',
|
||||||
|
'A plan must be created before updating tasks'
|
||||||
|
),
|
||||||
|
|
||||||
|
// Session state must exist
|
||||||
|
ToolDependency::sessionState(
|
||||||
|
'active_plan_id',
|
||||||
|
'An active plan must be selected'
|
||||||
|
),
|
||||||
|
|
||||||
|
// Context value required
|
||||||
|
ToolDependency::contextExists(
|
||||||
|
'workspace_id',
|
||||||
|
'Workspace context is required'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
// Dependencies are validated before handle() is called
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Types
|
||||||
|
|
||||||
|
| Type | Use Case |
|
||||||
|
|------|----------|
|
||||||
|
| `TOOL_CALLED` | Another tool must be executed in session |
|
||||||
|
| `SESSION_STATE` | A session variable must exist |
|
||||||
|
| `CONTEXT_EXISTS` | A context value must be present |
|
||||||
|
| `ENTITY_EXISTS` | A database entity must exist |
|
||||||
|
| `CUSTOM` | Custom validation logic |
|
||||||
|
|
||||||
|
### Creating Dependencies
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Tool must be called first
|
||||||
|
ToolDependency::toolCalled('list_tables');
|
||||||
|
|
||||||
|
// Session state required
|
||||||
|
ToolDependency::sessionState('selected_table');
|
||||||
|
|
||||||
|
// Context value required
|
||||||
|
ToolDependency::contextExists('workspace_id');
|
||||||
|
|
||||||
|
// Entity must exist
|
||||||
|
ToolDependency::entityExists('Plan', 'A plan must exist', [
|
||||||
|
'id_param' => 'plan_id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Custom validation
|
||||||
|
ToolDependency::custom('billing_active', 'Billing must be active');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Dependencies
|
||||||
|
|
||||||
|
Mark dependencies as optional (warns but doesn't block):
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function dependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ToolDependency::toolCalled('cache_warm')
|
||||||
|
->asOptional(), // Soft dependency
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Dependency Validation
|
||||||
|
|
||||||
|
Use the `ValidatesDependencies` trait for inline validation:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Mcp\Tools\Concerns\ValidatesDependencies;
|
||||||
|
|
||||||
|
class MyTool extends Tool
|
||||||
|
{
|
||||||
|
use ValidatesDependencies;
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$context = ['session_id' => $request->input('session_id')];
|
||||||
|
|
||||||
|
// Throws if dependencies not met
|
||||||
|
$this->validateDependencies($context);
|
||||||
|
|
||||||
|
// Or check without throwing
|
||||||
|
if (!$this->dependenciesMet($context)) {
|
||||||
|
$missing = $this->getMissingDependencies($context);
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'error' => 'Dependencies not met',
|
||||||
|
'missing' => array_map(fn($d) => $d->key, $missing),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registering Tools
|
||||||
|
|
||||||
|
Register tools via the `McpToolsRegistering` event in your module:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mod\Blog;
|
||||||
|
|
||||||
|
use Core\Events\McpToolsRegistering;
|
||||||
|
use Mod\Blog\Tools\CreatePostTool;
|
||||||
|
use Mod\Blog\Tools\ListPostsTool;
|
||||||
|
|
||||||
|
class Boot
|
||||||
|
{
|
||||||
|
public static array $listens = [
|
||||||
|
McpToolsRegistering::class => 'onMcpTools',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function onMcpTools(McpToolsRegistering $event): void
|
||||||
|
{
|
||||||
|
$event->tool('blog:list-posts', ListPostsTool::class);
|
||||||
|
$event->tool('blog:create-post', CreatePostTool::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Naming Conventions
|
||||||
|
|
||||||
|
Use consistent naming:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Pattern: module:action-resource
|
||||||
|
'blog:list-posts' // List resources
|
||||||
|
'blog:get-post' // Get single resource
|
||||||
|
'blog:create-post' // Create resource
|
||||||
|
'blog:update-post' // Update resource
|
||||||
|
'blog:delete-post' // Delete resource
|
||||||
|
|
||||||
|
// Sub-modules
|
||||||
|
'commerce:billing:get-status'
|
||||||
|
'commerce:coupon:create'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Formats
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```php
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $result,
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response
|
||||||
|
|
||||||
|
```php
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'error' => 'Specific error message',
|
||||||
|
'code' => 'ERROR_CODE',
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paginated Response
|
||||||
|
|
||||||
|
```php
|
||||||
|
$posts = Post::paginate($perPage);
|
||||||
|
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'data' => $posts->items(),
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $posts->currentPage(),
|
||||||
|
'last_page' => $posts->lastPage(),
|
||||||
|
'per_page' => $posts->perPage(),
|
||||||
|
'total' => $posts->total(),
|
||||||
|
],
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Response
|
||||||
|
|
||||||
|
```php
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'count' => $items->count(),
|
||||||
|
'items' => $items->map(fn($item) => [
|
||||||
|
'id' => $item->id,
|
||||||
|
'name' => $item->name,
|
||||||
|
])->all(),
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### 1. Never Trust User-Supplied IDs for Authorization
|
||||||
|
|
||||||
|
```php
|
||||||
|
// BAD: Using workspace_id from request
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$workspaceId = $request->input('workspace_id'); // Attacker can change this!
|
||||||
|
$posts = Post::where('workspace_id', $workspaceId)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD: Using authenticated workspace context
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$workspaceId = $this->getWorkspaceId(); // From auth context
|
||||||
|
$posts = Post::where('workspace_id', $workspaceId)->get();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Validate Resource Ownership
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$postId = $request->input('post_id');
|
||||||
|
$post = Post::findOrFail($postId);
|
||||||
|
|
||||||
|
// Always validate ownership before access
|
||||||
|
$this->validateResourceOwnership($post->workspace_id, 'post');
|
||||||
|
|
||||||
|
return Response::text(json_encode($post->toArray()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Sanitize and Limit Input
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
// Limit result sets
|
||||||
|
$limit = min($request->input('limit', 10), 100);
|
||||||
|
|
||||||
|
// Sanitize string input
|
||||||
|
$search = strip_tags($request->input('search', ''));
|
||||||
|
$search = substr($search, 0, 255);
|
||||||
|
|
||||||
|
// Validate enum values
|
||||||
|
$status = $request->input('status');
|
||||||
|
if ($status && !in_array($status, ['draft', 'published', 'archived'])) {
|
||||||
|
return Response::text(json_encode(['error' => 'Invalid status']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Log Sensitive Operations
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
Log::info('MCP tool executed', [
|
||||||
|
'tool' => 'delete-post',
|
||||||
|
'workspace_id' => $this->getWorkspaceId(),
|
||||||
|
'post_id' => $request->input('post_id'),
|
||||||
|
'user' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Perform operation...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Use Read-Only Database Connections for Queries
|
||||||
|
|
||||||
|
```php
|
||||||
|
// For query tools, use read-only connection
|
||||||
|
$connection = config('mcp.database.connection', 'readonly');
|
||||||
|
$results = DB::connection($connection)->select($query);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Sanitize Error Messages
|
||||||
|
|
||||||
|
```php
|
||||||
|
try {
|
||||||
|
// Operation...
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Log full error for debugging
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
// Return sanitized message to client
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'error' => 'Operation failed. Please try again.',
|
||||||
|
'code' => 'OPERATION_FAILED',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Implement Rate Limiting
|
||||||
|
|
||||||
|
Tools should respect quota limits:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mcp\Services\McpQuotaService;
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$quota = app(McpQuotaService::class);
|
||||||
|
$workspace = $this->getWorkspace();
|
||||||
|
|
||||||
|
if (!$quota->canExecute($workspace, $this->name())) {
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'error' => 'Rate limit exceeded',
|
||||||
|
'code' => 'QUOTA_EXCEEDED',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute tool...
|
||||||
|
|
||||||
|
$quota->recordExecution($workspace, $this->name());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Tools
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Mcp;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Mod\Blog\Tools\ListPostsTool;
|
||||||
|
use Mod\Blog\Models\Post;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Mod\Mcp\Context\WorkspaceContext;
|
||||||
|
|
||||||
|
class ListPostsToolTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_lists_posts(): void
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
Post::factory()->count(5)->create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tool = new ListPostsTool();
|
||||||
|
$tool->setWorkspaceContext(
|
||||||
|
WorkspaceContext::fromWorkspace($workspace)
|
||||||
|
);
|
||||||
|
|
||||||
|
$request = new \Laravel\Mcp\Request([
|
||||||
|
'limit' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $tool->handle($request);
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
$this->assertCount(5, $data['posts']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_respects_workspace_isolation(): void
|
||||||
|
{
|
||||||
|
$workspace1 = Workspace::factory()->create();
|
||||||
|
$workspace2 = Workspace::factory()->create();
|
||||||
|
|
||||||
|
Post::factory()->count(3)->create(['workspace_id' => $workspace1->id]);
|
||||||
|
Post::factory()->count(2)->create(['workspace_id' => $workspace2->id]);
|
||||||
|
|
||||||
|
$tool = new ListPostsTool();
|
||||||
|
$tool->setWorkspace($workspace1);
|
||||||
|
|
||||||
|
$request = new \Laravel\Mcp\Request([]);
|
||||||
|
$response = $tool->handle($request);
|
||||||
|
$data = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
// Should only see workspace1's posts
|
||||||
|
$this->assertCount(3, $data['posts']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_without_workspace_context(): void
|
||||||
|
{
|
||||||
|
$this->expectException(MissingWorkspaceContextException::class);
|
||||||
|
|
||||||
|
$tool = new ListPostsTool();
|
||||||
|
// Not setting workspace context
|
||||||
|
|
||||||
|
$tool->handle(new \Laravel\Mcp\Request([]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here's a complete tool implementation following all best practices:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Commerce\Tools;
|
||||||
|
|
||||||
|
use Core\Mod\Commerce\Models\Invoice;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Mod\Mcp\Tools\Concerns\RequiresWorkspaceContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List invoices for the authenticated workspace.
|
||||||
|
*
|
||||||
|
* SECURITY: Uses authenticated workspace context to prevent cross-tenant access.
|
||||||
|
*/
|
||||||
|
class ListInvoicesTool extends Tool
|
||||||
|
{
|
||||||
|
use RequiresWorkspaceContext;
|
||||||
|
|
||||||
|
protected string $description = 'List invoices for your workspace with optional status filter';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
// Get workspace from auth context (never from request params)
|
||||||
|
$workspaceId = $this->getWorkspaceId();
|
||||||
|
|
||||||
|
// Validate and sanitize inputs
|
||||||
|
$status = $request->input('status');
|
||||||
|
if ($status && !in_array($status, ['paid', 'pending', 'overdue', 'void'])) {
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'error' => 'Invalid status. Use: paid, pending, overdue, void',
|
||||||
|
'code' => 'VALIDATION_ERROR',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min($request->input('limit', 10), 50);
|
||||||
|
|
||||||
|
// Query with workspace scope
|
||||||
|
$query = Invoice::with('order')
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->latest();
|
||||||
|
|
||||||
|
if ($status) {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoices = $query->limit($limit)->get();
|
||||||
|
|
||||||
|
return Response::text(json_encode([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'count' => $invoices->count(),
|
||||||
|
'invoices' => $invoices->map(fn ($invoice) => [
|
||||||
|
'id' => $invoice->id,
|
||||||
|
'invoice_number' => $invoice->invoice_number,
|
||||||
|
'status' => $invoice->status,
|
||||||
|
'total' => (float) $invoice->total,
|
||||||
|
'currency' => $invoice->currency,
|
||||||
|
'issue_date' => $invoice->issue_date?->toDateString(),
|
||||||
|
'due_date' => $invoice->due_date?->toDateString(),
|
||||||
|
'is_overdue' => $invoice->isOverdue(),
|
||||||
|
])->all(),
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $schema->string('Filter by status: paid, pending, overdue, void'),
|
||||||
|
'limit' => $schema->integer('Maximum invoices to return (default 10, max 50)'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [SQL Security](/packages/mcp/sql-security) - Safe query patterns
|
||||||
|
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
|
||||||
|
- [Tool Analytics](/packages/mcp/analytics) - Usage tracking
|
||||||
|
- [Quotas](/packages/mcp/quotas) - Rate limiting
|
||||||
605
docs/packages/mcp/sql-security.md
Normal file
605
docs/packages/mcp/sql-security.md
Normal file
|
|
@ -0,0 +1,605 @@
|
||||||
|
# Guide: SQL Security
|
||||||
|
|
||||||
|
This guide documents the security controls for the Query Database MCP tool, including allowed SQL patterns, forbidden operations, and parameterized query requirements.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The MCP Query Database tool provides AI agents with read-only SQL access. Multiple security layers protect against:
|
||||||
|
|
||||||
|
- SQL injection attacks
|
||||||
|
- Data modification/destruction
|
||||||
|
- Cross-tenant data access
|
||||||
|
- Resource exhaustion
|
||||||
|
- Information leakage
|
||||||
|
|
||||||
|
## Allowed SQL Patterns
|
||||||
|
|
||||||
|
### SELECT-Only Queries
|
||||||
|
|
||||||
|
Only `SELECT` statements are permitted. All queries must begin with `SELECT`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Allowed: Basic SELECT
|
||||||
|
SELECT * FROM posts WHERE status = 'published';
|
||||||
|
|
||||||
|
-- Allowed: Specific columns
|
||||||
|
SELECT id, title, created_at FROM posts;
|
||||||
|
|
||||||
|
-- Allowed: COUNT queries
|
||||||
|
SELECT COUNT(*) FROM users WHERE active = 1;
|
||||||
|
|
||||||
|
-- Allowed: Aggregation
|
||||||
|
SELECT status, COUNT(*) as count FROM posts GROUP BY status;
|
||||||
|
|
||||||
|
-- Allowed: JOIN queries
|
||||||
|
SELECT posts.title, users.name
|
||||||
|
FROM posts
|
||||||
|
JOIN users ON posts.user_id = users.id;
|
||||||
|
|
||||||
|
-- Allowed: ORDER BY and LIMIT
|
||||||
|
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
|
||||||
|
|
||||||
|
-- Allowed: WHERE with multiple conditions
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE status = 'published'
|
||||||
|
AND user_id = 42
|
||||||
|
AND created_at > '2024-01-01';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported Operators
|
||||||
|
|
||||||
|
WHERE clauses support these operators:
|
||||||
|
|
||||||
|
| Operator | Example |
|
||||||
|
|----------|---------|
|
||||||
|
| `=` | `WHERE status = 'active'` |
|
||||||
|
| `!=`, `<>` | `WHERE status != 'deleted'` |
|
||||||
|
| `>`, `>=` | `WHERE created_at > '2024-01-01'` |
|
||||||
|
| `<`, `<=` | `WHERE views < 1000` |
|
||||||
|
| `LIKE` | `WHERE title LIKE '%search%'` |
|
||||||
|
| `IN` | `WHERE status IN ('draft', 'published')` |
|
||||||
|
| `BETWEEN` | `WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'` |
|
||||||
|
| `IS NULL` | `WHERE deleted_at IS NULL` |
|
||||||
|
| `IS NOT NULL` | `WHERE email IS NOT NULL` |
|
||||||
|
| `AND` | `WHERE a = 1 AND b = 2` |
|
||||||
|
| `OR` | `WHERE status = 'draft' OR status = 'review'` |
|
||||||
|
|
||||||
|
## Forbidden Operations
|
||||||
|
|
||||||
|
### Data Modification (Blocked)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: INSERT
|
||||||
|
INSERT INTO users (name) VALUES ('attacker');
|
||||||
|
|
||||||
|
-- BLOCKED: UPDATE
|
||||||
|
UPDATE users SET role = 'admin' WHERE id = 1;
|
||||||
|
|
||||||
|
-- BLOCKED: DELETE
|
||||||
|
DELETE FROM users WHERE id = 1;
|
||||||
|
|
||||||
|
-- BLOCKED: REPLACE
|
||||||
|
REPLACE INTO users (id, name) VALUES (1, 'changed');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Modification (Blocked)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: DROP
|
||||||
|
DROP TABLE users;
|
||||||
|
DROP DATABASE production;
|
||||||
|
|
||||||
|
-- BLOCKED: TRUNCATE
|
||||||
|
TRUNCATE TABLE logs;
|
||||||
|
|
||||||
|
-- BLOCKED: ALTER
|
||||||
|
ALTER TABLE users ADD COLUMN backdoor TEXT;
|
||||||
|
|
||||||
|
-- BLOCKED: CREATE
|
||||||
|
CREATE TABLE malicious_table (...);
|
||||||
|
|
||||||
|
-- BLOCKED: RENAME
|
||||||
|
RENAME TABLE users TO users_backup;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Operations (Blocked)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: GRANT
|
||||||
|
GRANT ALL ON *.* TO 'attacker'@'%';
|
||||||
|
|
||||||
|
-- BLOCKED: REVOKE
|
||||||
|
REVOKE SELECT ON database.* FROM 'user'@'%';
|
||||||
|
|
||||||
|
-- BLOCKED: FLUSH
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Operations (Blocked)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: File operations
|
||||||
|
SELECT * FROM posts INTO OUTFILE '/tmp/data.csv';
|
||||||
|
SELECT LOAD_FILE('/etc/passwd');
|
||||||
|
LOAD DATA INFILE '/etc/passwd' INTO TABLE users;
|
||||||
|
|
||||||
|
-- BLOCKED: Execution
|
||||||
|
EXECUTE prepared_statement;
|
||||||
|
CALL stored_procedure();
|
||||||
|
PREPARE stmt FROM 'SELECT ...';
|
||||||
|
|
||||||
|
-- BLOCKED: Variables
|
||||||
|
SET @var = (SELECT password FROM users);
|
||||||
|
SET GLOBAL max_connections = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Blocked Keywords List
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Data modification
|
||||||
|
'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'TRUNCATE'
|
||||||
|
|
||||||
|
// Schema changes
|
||||||
|
'DROP', 'ALTER', 'CREATE', 'RENAME'
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
'GRANT', 'REVOKE', 'FLUSH'
|
||||||
|
|
||||||
|
// System
|
||||||
|
'KILL', 'RESET', 'PURGE'
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
'INTO OUTFILE', 'INTO DUMPFILE', 'LOAD_FILE', 'LOAD DATA'
|
||||||
|
|
||||||
|
// Execution
|
||||||
|
'EXECUTE', 'EXEC', 'PREPARE', 'DEALLOCATE', 'CALL'
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
'SET '
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQL Injection Prevention
|
||||||
|
|
||||||
|
### Dangerous Patterns (Detected and Blocked)
|
||||||
|
|
||||||
|
The validator detects and blocks common injection patterns:
|
||||||
|
|
||||||
|
#### Stacked Queries
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: Multiple statements
|
||||||
|
SELECT * FROM posts; DROP TABLE users;
|
||||||
|
SELECT * FROM posts; DELETE FROM logs;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UNION Injection
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: UNION attacks
|
||||||
|
SELECT * FROM posts WHERE id = 1 UNION SELECT password FROM users;
|
||||||
|
SELECT * FROM posts UNION ALL SELECT * FROM secrets;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Comment Obfuscation
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: Comments hiding keywords
|
||||||
|
SELECT * FROM posts WHERE id = 1 /**/UNION/**/SELECT password FROM users;
|
||||||
|
SELECT * FROM posts; -- DROP TABLE users
|
||||||
|
SELECT * FROM posts # DELETE FROM logs
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Hex Encoding
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: Hex-encoded strings
|
||||||
|
SELECT * FROM posts WHERE id = 0x313B44524F50205441424C4520757365727320;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Time-Based Attacks
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: Timing attacks
|
||||||
|
SELECT * FROM posts WHERE id = 1 AND SLEEP(10);
|
||||||
|
SELECT * FROM posts WHERE BENCHMARK(10000000, SHA1('test'));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### System Table Access
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: Information schema
|
||||||
|
SELECT * FROM information_schema.tables;
|
||||||
|
SELECT * FROM information_schema.columns WHERE table_name = 'users';
|
||||||
|
|
||||||
|
-- BLOCKED: MySQL system tables
|
||||||
|
SELECT * FROM mysql.user;
|
||||||
|
SELECT * FROM performance_schema.threads;
|
||||||
|
SELECT * FROM sys.session;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Subquery in WHERE
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- BLOCKED: Potential data exfiltration
|
||||||
|
SELECT * FROM posts WHERE id = (SELECT user_id FROM admins LIMIT 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detection Patterns
|
||||||
|
|
||||||
|
The validator uses these regex patterns to detect attacks:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Stacked queries
|
||||||
|
'/;\s*\S/i'
|
||||||
|
|
||||||
|
// UNION injection
|
||||||
|
'/\bUNION\b/i'
|
||||||
|
|
||||||
|
// Hex encoding
|
||||||
|
'/0x[0-9a-f]+/i'
|
||||||
|
|
||||||
|
// Dangerous functions
|
||||||
|
'/\bCHAR\s*\(/i'
|
||||||
|
'/\bBENCHMARK\s*\(/i'
|
||||||
|
'/\bSLEEP\s*\(/i'
|
||||||
|
|
||||||
|
// System tables
|
||||||
|
'/\bINFORMATION_SCHEMA\b/i'
|
||||||
|
'/\bmysql\./i'
|
||||||
|
'/\bperformance_schema\./i'
|
||||||
|
'/\bsys\./i'
|
||||||
|
|
||||||
|
// Subquery in WHERE
|
||||||
|
'/WHERE\s+.*\(\s*SELECT/i'
|
||||||
|
|
||||||
|
// Comment obfuscation
|
||||||
|
'/\/\*[^*]*\*\/\s*(?:UNION|SELECT|INSERT|UPDATE|DELETE|DROP)/i'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameterized Queries
|
||||||
|
|
||||||
|
**Always use parameter bindings** instead of string interpolation:
|
||||||
|
|
||||||
|
### Correct Usage
|
||||||
|
|
||||||
|
```php
|
||||||
|
// SAFE: Parameterized query
|
||||||
|
$result = $tool->execute([
|
||||||
|
'query' => 'SELECT * FROM posts WHERE user_id = ? AND status = ?',
|
||||||
|
'bindings' => [$userId, 'published'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SAFE: Multiple parameters
|
||||||
|
$result = $tool->execute([
|
||||||
|
'query' => 'SELECT * FROM orders WHERE created_at BETWEEN ? AND ? AND total > ?',
|
||||||
|
'bindings' => ['2024-01-01', '2024-12-31', 100.00],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incorrect Usage (Vulnerable)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// VULNERABLE: String interpolation
|
||||||
|
$result = $tool->execute([
|
||||||
|
'query' => "SELECT * FROM posts WHERE user_id = {$userId}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// VULNERABLE: Concatenation
|
||||||
|
$query = "SELECT * FROM posts WHERE status = '" . $status . "'";
|
||||||
|
$result = $tool->execute(['query' => $query]);
|
||||||
|
|
||||||
|
// VULNERABLE: sprintf
|
||||||
|
$query = sprintf("SELECT * FROM posts WHERE id = %d", $id);
|
||||||
|
$result = $tool->execute(['query' => $query]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Bindings Matter
|
||||||
|
|
||||||
|
With bindings, malicious input is escaped automatically:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// User input
|
||||||
|
$userInput = "'; DROP TABLE users; --";
|
||||||
|
|
||||||
|
// With bindings: SAFE (input is escaped)
|
||||||
|
$tool->execute([
|
||||||
|
'query' => 'SELECT * FROM posts WHERE title = ?',
|
||||||
|
'bindings' => [$userInput],
|
||||||
|
]);
|
||||||
|
// Executed as: SELECT * FROM posts WHERE title = '\'; DROP TABLE users; --'
|
||||||
|
|
||||||
|
// Without bindings: VULNERABLE
|
||||||
|
$tool->execute([
|
||||||
|
'query' => "SELECT * FROM posts WHERE title = '$userInput'",
|
||||||
|
]);
|
||||||
|
// Executed as: SELECT * FROM posts WHERE title = ''; DROP TABLE users; --'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Whitelist-Based Validation
|
||||||
|
|
||||||
|
The validator uses a whitelist approach, only allowing queries matching known-safe patterns:
|
||||||
|
|
||||||
|
### Default Whitelist Patterns
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Simple SELECT with optional WHERE
|
||||||
|
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+`?\w+`?
|
||||||
|
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
|
||||||
|
(\s+ORDER\s+BY\s+[\w\s,`]+)?
|
||||||
|
(\s+LIMIT\s+\d+)?;?\s*$/i'
|
||||||
|
|
||||||
|
// COUNT queries
|
||||||
|
'/^\s*SELECT\s+COUNT\s*\(\s*\*?\s*\)
|
||||||
|
\s+FROM\s+`?\w+`?
|
||||||
|
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*;?\s*$/i'
|
||||||
|
|
||||||
|
// Explicit column list
|
||||||
|
'/^\s*SELECT\s+`?\w+`?(\s*,\s*`?\w+`?)*
|
||||||
|
\s+FROM\s+`?\w+`?
|
||||||
|
(\s+WHERE\s+[\w\s`.,!=<>\'"%()]+)*
|
||||||
|
(\s+ORDER\s+BY\s+[\w\s,`]+)?
|
||||||
|
(\s+LIMIT\s+\d+)?;?\s*$/i'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Custom Patterns
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/mcp.php
|
||||||
|
'database' => [
|
||||||
|
'use_whitelist' => true,
|
||||||
|
'whitelist_patterns' => [
|
||||||
|
// Allow specific JOIN pattern
|
||||||
|
'/^\s*SELECT\s+[\w\s,.*`]+\s+FROM\s+posts\s+JOIN\s+users\s+ON\s+posts\.user_id\s*=\s*users\.id/i',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection Security
|
||||||
|
|
||||||
|
### Allowed Connections
|
||||||
|
|
||||||
|
Only whitelisted database connections can be queried:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/mcp.php
|
||||||
|
'database' => [
|
||||||
|
'allowed_connections' => [
|
||||||
|
'mysql', // Primary database
|
||||||
|
'analytics', // Read-only analytics
|
||||||
|
'logs', // Application logs
|
||||||
|
],
|
||||||
|
'connection' => 'mcp_readonly', // Default MCP connection
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read-Only Database User
|
||||||
|
|
||||||
|
Create a dedicated read-only user for MCP:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Create read-only user
|
||||||
|
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'secure_password';
|
||||||
|
|
||||||
|
-- Grant SELECT only
|
||||||
|
GRANT SELECT ON app_database.* TO 'mcp_readonly'@'%';
|
||||||
|
|
||||||
|
-- Explicitly deny write operations
|
||||||
|
REVOKE INSERT, UPDATE, DELETE, DROP, CREATE, ALTER
|
||||||
|
ON app_database.* FROM 'mcp_readonly'@'%';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure in Laravel:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/database.php
|
||||||
|
'connections' => [
|
||||||
|
'mcp_readonly' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => env('DB_HOST'),
|
||||||
|
'database' => env('DB_DATABASE'),
|
||||||
|
'username' => env('MCP_DB_USER', 'mcp_readonly'),
|
||||||
|
'password' => env('MCP_DB_PASSWORD'),
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
|
'strict' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
## Blocked Tables
|
||||||
|
|
||||||
|
Configure tables that cannot be queried:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/mcp.php
|
||||||
|
'database' => [
|
||||||
|
'blocked_tables' => [
|
||||||
|
'users', // User credentials
|
||||||
|
'password_resets', // Password tokens
|
||||||
|
'sessions', // Session data
|
||||||
|
'api_keys', // API credentials
|
||||||
|
'oauth_access_tokens', // OAuth tokens
|
||||||
|
'personal_access_tokens', // Sanctum tokens
|
||||||
|
'failed_jobs', // Job queue data
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator checks for table references in multiple formats:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// All these are blocked for 'users' table:
|
||||||
|
'SELECT * FROM users'
|
||||||
|
'SELECT * FROM `users`'
|
||||||
|
'SELECT posts.*, users.name FROM posts JOIN users...'
|
||||||
|
'SELECT users.email FROM ...'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Row Limits
|
||||||
|
|
||||||
|
Automatic row limits prevent data exfiltration:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/mcp.php
|
||||||
|
'database' => [
|
||||||
|
'max_rows' => 1000, // Maximum rows per query
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
If query doesn't include LIMIT, one is added automatically:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Query without LIMIT
|
||||||
|
$tool->execute(['query' => 'SELECT * FROM posts']);
|
||||||
|
// Becomes: SELECT * FROM posts LIMIT 1000
|
||||||
|
|
||||||
|
// Query with smaller LIMIT (preserved)
|
||||||
|
$tool->execute(['query' => 'SELECT * FROM posts LIMIT 10']);
|
||||||
|
// Stays: SELECT * FROM posts LIMIT 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Forbidden Query Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid Structure Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Query rejected: Query must begin with SELECT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not Whitelisted Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Query rejected: Query does not match any allowed pattern"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sanitized SQL Errors
|
||||||
|
|
||||||
|
Database errors are sanitized to prevent information leakage:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Original error (logged for debugging)
|
||||||
|
"SQLSTATE[42S02]: Table 'production.secret_table' doesn't exist at 192.168.1.100"
|
||||||
|
|
||||||
|
// Sanitized response (returned to client)
|
||||||
|
"Query execution failed: Table '[path]' doesn't exist at [ip]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/mcp.php
|
||||||
|
return [
|
||||||
|
'database' => [
|
||||||
|
// Database connection for MCP queries
|
||||||
|
'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'),
|
||||||
|
|
||||||
|
// Use whitelist validation (recommended: true)
|
||||||
|
'use_whitelist' => true,
|
||||||
|
|
||||||
|
// Custom whitelist patterns (regex)
|
||||||
|
'whitelist_patterns' => [],
|
||||||
|
|
||||||
|
// Tables that cannot be queried
|
||||||
|
'blocked_tables' => [
|
||||||
|
'users',
|
||||||
|
'password_resets',
|
||||||
|
'sessions',
|
||||||
|
'api_keys',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Maximum rows per query
|
||||||
|
'max_rows' => 1000,
|
||||||
|
|
||||||
|
// Query execution timeout (milliseconds)
|
||||||
|
'timeout' => 5000,
|
||||||
|
|
||||||
|
// Enable EXPLAIN analysis
|
||||||
|
'enable_explain' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Security
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tests\TestCase;
|
||||||
|
use Core\Mod\Mcp\Services\SqlQueryValidator;
|
||||||
|
use Core\Mod\Mcp\Exceptions\ForbiddenQueryException;
|
||||||
|
|
||||||
|
class SqlSecurityTest extends TestCase
|
||||||
|
{
|
||||||
|
private SqlQueryValidator $validator;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->validator = new SqlQueryValidator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blocks_delete(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ForbiddenQueryException::class);
|
||||||
|
$this->validator->validate('DELETE FROM users');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blocks_union_injection(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ForbiddenQueryException::class);
|
||||||
|
$this->validator->validate("SELECT * FROM posts UNION SELECT password FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blocks_stacked_queries(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ForbiddenQueryException::class);
|
||||||
|
$this->validator->validate("SELECT * FROM posts; DROP TABLE users");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_blocks_system_tables(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ForbiddenQueryException::class);
|
||||||
|
$this->validator->validate("SELECT * FROM information_schema.tables");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_allows_safe_select(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate("SELECT id, title FROM posts WHERE status = 'published'");
|
||||||
|
$this->assertTrue(true); // No exception = pass
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_allows_count(): void
|
||||||
|
{
|
||||||
|
$this->validator->validate("SELECT COUNT(*) FROM posts");
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices Summary
|
||||||
|
|
||||||
|
1. **Always use parameterized queries** - Never interpolate values into SQL strings
|
||||||
|
2. **Use a read-only database user** - Database-level protection against modifications
|
||||||
|
3. **Configure blocked tables** - Prevent access to sensitive data
|
||||||
|
4. **Enable whitelist validation** - Only allow known-safe query patterns
|
||||||
|
5. **Set appropriate row limits** - Prevent large data exports
|
||||||
|
6. **Review logs regularly** - Monitor for suspicious query patterns
|
||||||
|
7. **Test security controls** - Include injection tests in your test suite
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Query Database Tool](/packages/mcp/query-database) - Tool usage
|
||||||
|
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
|
||||||
|
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Tool development
|
||||||
739
docs/packages/mcp/tools-reference.md
Normal file
739
docs/packages/mcp/tools-reference.md
Normal file
|
|
@ -0,0 +1,739 @@
|
||||||
|
# API Reference: MCP Tools
|
||||||
|
|
||||||
|
Complete reference for all MCP tools including parameters, response formats, and error handling.
|
||||||
|
|
||||||
|
## Database Tools
|
||||||
|
|
||||||
|
### query_database
|
||||||
|
|
||||||
|
Execute read-only SQL queries against the database.
|
||||||
|
|
||||||
|
**Description:** Execute a read-only SQL SELECT query against the database
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `query` | string | Yes | SQL SELECT query to execute. Only read-only SELECT queries are permitted. |
|
||||||
|
| `explain` | boolean | No | If true, runs EXPLAIN on the query instead of executing it. Useful for query optimization. Default: `false` |
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "query_database",
|
||||||
|
"arguments": {
|
||||||
|
"query": "SELECT id, title, status FROM posts WHERE status = 'published' LIMIT 10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"id": 1, "title": "First Post", "status": "published"},
|
||||||
|
{"id": 2, "title": "Second Post", "status": "published"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**With EXPLAIN:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "query_database",
|
||||||
|
"arguments": {
|
||||||
|
"query": "SELECT * FROM posts WHERE status = 'published'",
|
||||||
|
"explain": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**EXPLAIN Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"explain": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"select_type": "SIMPLE",
|
||||||
|
"table": "posts",
|
||||||
|
"type": "ref",
|
||||||
|
"key": "idx_status",
|
||||||
|
"rows": 150,
|
||||||
|
"Extra": "Using index"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"query": "SELECT * FROM posts WHERE status = 'published' LIMIT 1000",
|
||||||
|
"interpretation": [
|
||||||
|
{
|
||||||
|
"table": "posts",
|
||||||
|
"analysis": [
|
||||||
|
"GOOD: Using index: idx_status"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response - Forbidden Query:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Query rejected: Disallowed SQL keyword 'DELETE' detected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response - Invalid Structure:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Query rejected: Query must begin with SELECT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Notes:**
|
||||||
|
- Only SELECT queries are allowed
|
||||||
|
- Blocked keywords: INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE
|
||||||
|
- UNION queries are blocked
|
||||||
|
- System tables (information_schema, mysql.*) are blocked
|
||||||
|
- Automatic LIMIT applied if not specified
|
||||||
|
- Use read-only database connection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### list_tables
|
||||||
|
|
||||||
|
List all database tables in the application.
|
||||||
|
|
||||||
|
**Description:** List all database tables
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "list_tables",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"users",
|
||||||
|
"posts",
|
||||||
|
"comments",
|
||||||
|
"tags",
|
||||||
|
"categories",
|
||||||
|
"media",
|
||||||
|
"migrations",
|
||||||
|
"jobs"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Notes:**
|
||||||
|
- Returns table names only, not structure
|
||||||
|
- Some tables may be filtered based on configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commerce Tools
|
||||||
|
|
||||||
|
### get_billing_status
|
||||||
|
|
||||||
|
Get billing status for the authenticated workspace.
|
||||||
|
|
||||||
|
**Description:** Get billing status for your workspace including subscription, current plan, and billing period
|
||||||
|
|
||||||
|
**Parameters:** None (workspace from authentication context)
|
||||||
|
|
||||||
|
**Requires:** Workspace Context
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_billing_status",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workspace": {
|
||||||
|
"id": 42,
|
||||||
|
"name": "Acme Corp"
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"id": 123,
|
||||||
|
"status": "active",
|
||||||
|
"gateway": "stripe",
|
||||||
|
"billing_cycle": "monthly",
|
||||||
|
"current_period_start": "2024-01-01T00:00:00+00:00",
|
||||||
|
"current_period_end": "2024-02-01T00:00:00+00:00",
|
||||||
|
"days_until_renewal": 15,
|
||||||
|
"cancel_at_period_end": false,
|
||||||
|
"on_trial": false,
|
||||||
|
"trial_ends_at": null
|
||||||
|
},
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"code": "professional",
|
||||||
|
"name": "Professional Plan",
|
||||||
|
"status": "active",
|
||||||
|
"expires_at": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `workspace.id` | integer | Workspace ID |
|
||||||
|
| `workspace.name` | string | Workspace name |
|
||||||
|
| `subscription.status` | string | active, trialing, past_due, canceled |
|
||||||
|
| `subscription.billing_cycle` | string | monthly, yearly |
|
||||||
|
| `subscription.days_until_renewal` | integer | Days until next billing |
|
||||||
|
| `subscription.on_trial` | boolean | Currently in trial period |
|
||||||
|
| `packages` | array | Active feature packages |
|
||||||
|
|
||||||
|
**Error Response - No Workspace Context:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "MCP tool 'get_billing_status' requires workspace context. Authenticate with an API key or user session."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### list_invoices
|
||||||
|
|
||||||
|
List invoices for the authenticated workspace.
|
||||||
|
|
||||||
|
**Description:** List invoices for your workspace with optional status filter
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `status` | string | No | Filter by status: paid, pending, overdue, void |
|
||||||
|
| `limit` | integer | No | Maximum invoices to return. Default: 10, Max: 50 |
|
||||||
|
|
||||||
|
**Requires:** Workspace Context
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "list_invoices",
|
||||||
|
"arguments": {
|
||||||
|
"status": "paid",
|
||||||
|
"limit": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workspace_id": 42,
|
||||||
|
"count": 5,
|
||||||
|
"invoices": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"invoice_number": "INV-2024-001",
|
||||||
|
"status": "paid",
|
||||||
|
"subtotal": 99.00,
|
||||||
|
"discount_amount": 0.00,
|
||||||
|
"tax_amount": 19.80,
|
||||||
|
"total": 118.80,
|
||||||
|
"amount_paid": 118.80,
|
||||||
|
"amount_due": 0.00,
|
||||||
|
"currency": "GBP",
|
||||||
|
"issue_date": "2024-01-01",
|
||||||
|
"due_date": "2024-01-15",
|
||||||
|
"paid_at": "2024-01-10T14:30:00+00:00",
|
||||||
|
"is_overdue": false,
|
||||||
|
"order_number": "ORD-2024-001"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `invoice_number` | string | Unique invoice identifier |
|
||||||
|
| `status` | string | paid, pending, overdue, void |
|
||||||
|
| `total` | number | Total amount including tax |
|
||||||
|
| `amount_due` | number | Remaining amount to pay |
|
||||||
|
| `is_overdue` | boolean | Past due date with unpaid balance |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### upgrade_plan
|
||||||
|
|
||||||
|
Preview or execute a plan upgrade/downgrade.
|
||||||
|
|
||||||
|
**Description:** Preview or execute a plan upgrade/downgrade for your workspace subscription
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `package_code` | string | Yes | Code of the new package (e.g., agency, enterprise) |
|
||||||
|
| `preview` | boolean | No | If true, only preview without executing. Default: `true` |
|
||||||
|
| `immediate` | boolean | No | If true, apply immediately; false schedules for period end. Default: `true` |
|
||||||
|
|
||||||
|
**Requires:** Workspace Context
|
||||||
|
|
||||||
|
**Example Request - Preview:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "upgrade_plan",
|
||||||
|
"arguments": {
|
||||||
|
"package_code": "enterprise",
|
||||||
|
"preview": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preview Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preview": true,
|
||||||
|
"current_package": "professional",
|
||||||
|
"new_package": "enterprise",
|
||||||
|
"proration": {
|
||||||
|
"is_upgrade": true,
|
||||||
|
"is_downgrade": false,
|
||||||
|
"current_plan_price": 99.00,
|
||||||
|
"new_plan_price": 299.00,
|
||||||
|
"credit_amount": 49.50,
|
||||||
|
"prorated_new_cost": 149.50,
|
||||||
|
"net_amount": 100.00,
|
||||||
|
"requires_payment": true,
|
||||||
|
"days_remaining": 15,
|
||||||
|
"currency": "GBP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Execute Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"immediate": true,
|
||||||
|
"current_package": "professional",
|
||||||
|
"new_package": "enterprise",
|
||||||
|
"proration": {
|
||||||
|
"is_upgrade": true,
|
||||||
|
"net_amount": 100.00
|
||||||
|
},
|
||||||
|
"subscription_status": "active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response - Package Not Found:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Package not found",
|
||||||
|
"available_packages": ["starter", "professional", "agency", "enterprise"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### create_coupon
|
||||||
|
|
||||||
|
Create a new discount coupon code.
|
||||||
|
|
||||||
|
**Description:** Create a new discount coupon code
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `code` | string | Yes | Unique coupon code (uppercase letters, numbers, hyphens, underscores) |
|
||||||
|
| `name` | string | Yes | Display name for the coupon |
|
||||||
|
| `type` | string | No | Discount type: percentage or fixed_amount. Default: percentage |
|
||||||
|
| `value` | number | Yes | Discount value (1-100 for percentage, or fixed amount) |
|
||||||
|
| `duration` | string | No | How long discount applies: once, repeating, forever. Default: once |
|
||||||
|
| `max_uses` | integer | No | Maximum total uses (null for unlimited) |
|
||||||
|
| `valid_until` | string | No | Expiry date in YYYY-MM-DD format |
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_coupon",
|
||||||
|
"arguments": {
|
||||||
|
"code": "SUMMER25",
|
||||||
|
"name": "Summer Sale 2024",
|
||||||
|
"type": "percentage",
|
||||||
|
"value": 25,
|
||||||
|
"duration": "once",
|
||||||
|
"max_uses": 100,
|
||||||
|
"valid_until": "2024-08-31"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"coupon": {
|
||||||
|
"id": 42,
|
||||||
|
"code": "SUMMER25",
|
||||||
|
"name": "Summer Sale 2024",
|
||||||
|
"type": "percentage",
|
||||||
|
"value": 25.0,
|
||||||
|
"duration": "once",
|
||||||
|
"max_uses": 100,
|
||||||
|
"valid_until": "2024-08-31",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response - Invalid Code:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Invalid code format. Use only uppercase letters, numbers, hyphens, and underscores."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response - Duplicate Code:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "A coupon with this code already exists."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response - Invalid Percentage:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Percentage value must be between 1 and 100."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Tools
|
||||||
|
|
||||||
|
### list_sites
|
||||||
|
|
||||||
|
List all sites managed by the platform.
|
||||||
|
|
||||||
|
**Description:** List all sites managed by Host Hub
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "list_sites",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "BioHost",
|
||||||
|
"domain": "link.host.uk.com",
|
||||||
|
"type": "WordPress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SocialHost",
|
||||||
|
"domain": "social.host.uk.com",
|
||||||
|
"type": "Laravel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AnalyticsHost",
|
||||||
|
"domain": "analytics.host.uk.com",
|
||||||
|
"type": "Node.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### list_routes
|
||||||
|
|
||||||
|
List all web routes in the application.
|
||||||
|
|
||||||
|
**Description:** List all web routes in the application
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "list_routes",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"uri": "/",
|
||||||
|
"methods": ["GET", "HEAD"],
|
||||||
|
"name": "home"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "/login",
|
||||||
|
"methods": ["GET", "HEAD"],
|
||||||
|
"name": "login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "/api/posts",
|
||||||
|
"methods": ["GET", "HEAD"],
|
||||||
|
"name": "api.posts.index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "/api/posts/{post}",
|
||||||
|
"methods": ["GET", "HEAD"],
|
||||||
|
"name": "api.posts.show"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### get_stats
|
||||||
|
|
||||||
|
Get current system statistics.
|
||||||
|
|
||||||
|
**Description:** Get current system statistics for Host Hub
|
||||||
|
|
||||||
|
**Parameters:** None
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "get_stats",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_sites": 6,
|
||||||
|
"active_users": 128,
|
||||||
|
"page_views_30d": 12500,
|
||||||
|
"server_load": "23%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Error Responses
|
||||||
|
|
||||||
|
### Missing Workspace Context
|
||||||
|
|
||||||
|
Tools requiring workspace context return this when no API key or session is provided:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "MCP tool 'tool_name' requires workspace context. Authenticate with an API key or user session."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Status:** 403
|
||||||
|
|
||||||
|
### Missing Dependency
|
||||||
|
|
||||||
|
When a tool's dependencies aren't satisfied:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "dependency_not_met",
|
||||||
|
"message": "Dependencies not satisfied for tool 'update_task'",
|
||||||
|
"missing": [
|
||||||
|
{
|
||||||
|
"type": "tool_called",
|
||||||
|
"key": "create_plan",
|
||||||
|
"description": "A plan must be created before updating tasks"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"suggested_order": ["create_plan", "update_task"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Status:** 422
|
||||||
|
|
||||||
|
### Quota Exceeded
|
||||||
|
|
||||||
|
When workspace has exceeded their tool usage quota:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "quota_exceeded",
|
||||||
|
"message": "Daily tool quota exceeded for this workspace",
|
||||||
|
"current_usage": 1000,
|
||||||
|
"limit": 1000,
|
||||||
|
"resets_at": "2024-01-16T00:00:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Status:** 429
|
||||||
|
|
||||||
|
### Validation Error
|
||||||
|
|
||||||
|
When parameters fail validation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Validation failed",
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"details": {
|
||||||
|
"query": ["The query field is required"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Status:** 422
|
||||||
|
|
||||||
|
### Internal Error
|
||||||
|
|
||||||
|
When an unexpected error occurs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "An unexpected error occurred. Please try again.",
|
||||||
|
"code": "INTERNAL_ERROR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP Status:** 500
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### API Key Authentication
|
||||||
|
|
||||||
|
Include your API key in the Authorization header:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.example.com/mcp/tools/call \
|
||||||
|
-H "Authorization: Bearer sk_live_xxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tool": "get_billing_status", "arguments": {}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Authentication
|
||||||
|
|
||||||
|
For browser-based access, use session cookies:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
fetch('/mcp/tools/call', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tool: 'list_invoices',
|
||||||
|
arguments: { limit: 10 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Session ID
|
||||||
|
|
||||||
|
For tracking dependencies across tool calls, include a session ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.example.com/mcp/tools/call \
|
||||||
|
-H "Authorization: Bearer sk_live_xxxxx" \
|
||||||
|
-H "X-MCP-Session-ID: session_abc123" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tool": "update_task", "arguments": {...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Categories
|
||||||
|
|
||||||
|
### Query Tools
|
||||||
|
- `query_database` - Execute SQL queries
|
||||||
|
- `list_tables` - List database tables
|
||||||
|
|
||||||
|
### Commerce Tools
|
||||||
|
- `get_billing_status` - Get subscription status
|
||||||
|
- `list_invoices` - List workspace invoices
|
||||||
|
- `upgrade_plan` - Change subscription plan
|
||||||
|
- `create_coupon` - Create discount codes
|
||||||
|
|
||||||
|
### System Tools
|
||||||
|
- `list_sites` - List managed sites
|
||||||
|
- `list_routes` - List application routes
|
||||||
|
- `get_stats` - Get system statistics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
All tools return JSON responses. Success responses vary by tool, but error responses follow a consistent format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Human-readable error message",
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"details": {} // Optional additional information
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Error Codes:**
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `VALIDATION_ERROR` | Invalid parameters |
|
||||||
|
| `FORBIDDEN_QUERY` | SQL query blocked by security |
|
||||||
|
| `MISSING_WORKSPACE_CONTEXT` | Workspace authentication required |
|
||||||
|
| `QUOTA_EXCEEDED` | Usage limit reached |
|
||||||
|
| `NOT_FOUND` | Resource not found |
|
||||||
|
| `DEPENDENCY_NOT_MET` | Tool prerequisites not satisfied |
|
||||||
|
| `INTERNAL_ERROR` | Unexpected server error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [Creating MCP Tools](/packages/mcp/creating-mcp-tools) - Build custom tools
|
||||||
|
- [SQL Security](/packages/mcp/sql-security) - Query security rules
|
||||||
|
- [Workspace Context](/packages/mcp/workspace) - Multi-tenant isolation
|
||||||
|
- [Quotas](/packages/mcp/quotas) - Usage limits
|
||||||
|
- [Analytics](/packages/mcp/analytics) - Usage tracking
|
||||||
|
|
@ -151,26 +151,29 @@
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] **Guide: Creating Admin Panels** - Step-by-step guide
|
- [x] **Guide: Creating Admin Panels** - Step-by-step guide
|
||||||
- [ ] Document menu registration
|
- [x] Document menu registration
|
||||||
- [ ] Show modal creation examples
|
- [x] Show modal creation examples
|
||||||
- [ ] Explain authorization integration
|
- [x] Explain authorization integration
|
||||||
- [ ] Add complete example module
|
- [x] Add complete example module
|
||||||
- **Estimated effort:** 3-4 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/admin/creating-admin-panels.md`
|
||||||
|
|
||||||
- [ ] **Guide: HLCRF Deep Dive** - Advanced layout patterns
|
- [x] **Guide: HLCRF Deep Dive** - Advanced layout patterns
|
||||||
- [ ] Document all layout combinations
|
- [x] Document all layout combinations
|
||||||
- [ ] Show responsive design patterns
|
- [x] Show responsive design patterns
|
||||||
- [ ] Explain ID system in detail
|
- [x] Explain ID system in detail
|
||||||
- [ ] Add complex real-world examples
|
- [x] Add complex real-world examples
|
||||||
- **Estimated effort:** 4-5 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/admin/hlcrf-deep-dive.md`
|
||||||
|
|
||||||
- [ ] **API Reference: Components** - Component prop documentation
|
- [x] **API Reference: Components** - Component prop documentation
|
||||||
- [ ] Document all form component props
|
- [x] Document all form component props
|
||||||
- [ ] Add prop validation rules
|
- [x] Add prop validation rules
|
||||||
- [ ] Show authorization prop examples
|
- [x] Show authorization prop examples
|
||||||
- [ ] Include accessibility notes
|
- [x] Include accessibility notes
|
||||||
- **Estimated effort:** 3-4 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/admin/components-reference.md`
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
|
|
@ -217,5 +220,8 @@
|
||||||
- [x] **Search: Provider System** - Global search with multiple providers
|
- [x] **Search: Provider System** - Global search with multiple providers
|
||||||
- [x] **Search: Analytics** - Track search queries and results
|
- [x] **Search: Analytics** - Track search queries and results
|
||||||
- [x] **Documentation** - Complete admin package documentation
|
- [x] **Documentation** - Complete admin package documentation
|
||||||
|
- [x] **Guide: Creating Admin Panels** - Menu registration, modals, authorization, example module
|
||||||
|
- [x] **Guide: HLCRF Deep Dive** - Layout combinations, ID system, responsive patterns
|
||||||
|
- [x] **API Reference: Components** - Form component props with authorization examples
|
||||||
|
|
||||||
*See `changelog/2026/jan/` for completed features.*
|
*See `changelog/2026/jan/` for completed features.*
|
||||||
|
|
|
||||||
|
|
@ -172,26 +172,29 @@
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] **Guide: Building REST APIs** - Complete tutorial
|
- [x] **Guide: Building REST APIs** - Complete tutorial
|
||||||
- [ ] Document resource creation
|
- [x] Document resource creation
|
||||||
- [ ] Show pagination best practices
|
- [x] Show pagination best practices
|
||||||
- [ ] Explain filtering and sorting
|
- [x] Explain filtering and sorting
|
||||||
- [ ] Add authentication examples
|
- [x] Add authentication examples
|
||||||
- **Estimated effort:** 4-5 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/api/building-rest-apis.md`
|
||||||
|
|
||||||
- [ ] **Guide: Webhook Integration** - For API consumers
|
- [x] **Guide: Webhook Integration** - For API consumers
|
||||||
- [ ] Document signature verification
|
- [x] Document signature verification
|
||||||
- [ ] Show retry handling
|
- [x] Show retry handling
|
||||||
- [ ] Explain event types
|
- [x] Explain event types
|
||||||
- [ ] Add code examples (PHP, JS, Python)
|
- [x] Add code examples (PHP, JS, Python)
|
||||||
- **Estimated effort:** 3-4 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/api/webhook-integration.md`
|
||||||
|
|
||||||
- [ ] **API Reference: All Endpoints** - Complete OpenAPI spec
|
- [x] **API Reference: All Endpoints** - Complete OpenAPI spec
|
||||||
- [ ] Document all request parameters
|
- [x] Document all request parameters
|
||||||
- [ ] Add response examples
|
- [x] Add response examples
|
||||||
- [ ] Show error responses
|
- [x] Show error responses
|
||||||
- [ ] Include authentication notes
|
- [x] Include authentication notes
|
||||||
- **Estimated effort:** 6-8 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/api/endpoints-reference.md`
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,27 +211,30 @@
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] **Guide: Creating MCP Tools** - Comprehensive tutorial
|
- [x] **Guide: Creating MCP Tools** - Comprehensive tutorial
|
||||||
- [ ] Document tool interface
|
- [x] Document tool interface
|
||||||
- [ ] Show parameter validation
|
- [x] Show parameter validation
|
||||||
- [ ] Explain workspace context
|
- [x] Explain workspace context
|
||||||
- [ ] Add dependency examples
|
- [x] Add dependency examples
|
||||||
- [ ] Include security best practices
|
- [x] Include security best practices
|
||||||
- **Estimated effort:** 4-5 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/mcp/creating-mcp-tools.md`
|
||||||
|
|
||||||
- [ ] **Guide: SQL Security** - Safe query patterns
|
- [x] **Guide: SQL Security** - Safe query patterns
|
||||||
- [ ] Document allowed SQL patterns
|
- [x] Document allowed SQL patterns
|
||||||
- [ ] Show parameterized query examples
|
- [x] Show parameterized query examples
|
||||||
- [ ] Explain validation rules
|
- [x] Explain validation rules
|
||||||
- [ ] List forbidden operations
|
- [x] List forbidden operations
|
||||||
- **Estimated effort:** 3-4 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/mcp/sql-security.md`
|
||||||
|
|
||||||
- [ ] **API Reference: All MCP Tools** - Complete tool catalog
|
- [x] **API Reference: All MCP Tools** - Complete tool catalog
|
||||||
- [ ] Document each tool's parameters
|
- [x] Document each tool's parameters
|
||||||
- [ ] Add usage examples
|
- [x] Add usage examples
|
||||||
- [ ] Show response formats
|
- [x] Show response formats
|
||||||
- [ ] Include error cases
|
- [x] Include error cases
|
||||||
- **Estimated effort:** 5-6 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/mcp/tools-reference.md`
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
|
|
@ -295,6 +298,8 @@
|
||||||
- [x] **Tool Analytics System** - Complete usage tracking and metrics
|
- [x] **Tool Analytics System** - Complete usage tracking and metrics
|
||||||
- [x] **Quota System** - Tier-based limits with enforcement
|
- [x] **Quota System** - Tier-based limits with enforcement
|
||||||
- [x] **Workspace Context** - Automatic query scoping and validation
|
- [x] **Workspace Context** - Automatic query scoping and validation
|
||||||
- [x] **Documentation** - Complete MCP package documentation
|
- [x] **Documentation: Creating MCP Tools Guide** - Complete tutorial with workspace context, dependencies, security
|
||||||
|
- [x] **Documentation: SQL Security Guide** - Allowed patterns, forbidden operations, injection prevention
|
||||||
|
- [x] **Documentation: MCP Tools API Reference** - All tools with parameters, examples, error handling
|
||||||
|
|
||||||
*See `changelog/2026/jan/` for completed features and security fixes.*
|
*See `changelog/2026/jan/` for completed features and security fixes.*
|
||||||
|
|
|
||||||
|
|
@ -266,17 +266,19 @@
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] **API Docs: Service Contracts** - Document service pattern
|
- [x] **API Docs: Service Contracts** - Document service pattern
|
||||||
- [ ] Add examples for ServiceDefinition
|
- [x] Add examples for ServiceDefinition
|
||||||
- [ ] Document service versioning
|
- [x] Document service versioning
|
||||||
- [ ] Add dependency resolution examples
|
- [x] Add dependency resolution examples
|
||||||
- **Estimated effort:** 2-3 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/core/service-contracts.md`
|
||||||
|
|
||||||
- [ ] **API Docs: Seeder System** - Document seeder attributes
|
- [x] **API Docs: Seeder System** - Document seeder attributes
|
||||||
- [ ] Document dependency resolution
|
- [x] Document dependency resolution
|
||||||
- [ ] Add complex ordering examples
|
- [x] Add complex ordering examples
|
||||||
- [ ] Document circular dependency errors
|
- [x] Document circular dependency errors
|
||||||
- **Estimated effort:** 2-3 hours
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/core/seeder-system.md`
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue