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
|
||||
|
||||
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)
|
||||
No pending tasks.
|
||||
|
||||
---
|
||||
|
||||
## Package Changelogs
|
||||
|
||||
For complete feature lists and implementation details:
|
||||
- `packages/core-php/changelog/2026/jan/features.md`
|
||||
- `packages/core-admin/changelog/2026/jan/features.md`
|
||||
- `packages/core-api/changelog/2026/jan/features.md`
|
||||
- `packages/core-mcp/changelog/2026/jan/features.md`
|
||||
- `packages/core-mcp/changelog/2026/jan/security.md` ⚠️ Security fixes
|
||||
For completed features and implementation details, see each package's changelog:
|
||||
|
||||
- `packages/core-php/changelog/`
|
||||
- `packages/core-admin/changelog/`
|
||||
- `packages/core-api/changelog/`
|
||||
- `packages/core-mcp/changelog/`
|
||||
|
|
|
|||
|
|
@ -102,7 +102,9 @@ export default defineConfig({
|
|||
{ text: 'Activity Logging', link: '/packages/core/activity' },
|
||||
{ text: 'Media Processing', link: '/packages/core/media' },
|
||||
{ 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: 'Admin Menus', link: '/packages/admin/menus' },
|
||||
{ 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: 'Rate Limiting', link: '/packages/api/rate-limiting' },
|
||||
{ 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: 'Workspace Context', link: '/packages/mcp/workspace' },
|
||||
{ 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
|
||||
|
||||
- [ ] **Guide: Creating Admin Panels** - Step-by-step guide
|
||||
- [ ] Document menu registration
|
||||
- [ ] Show modal creation examples
|
||||
- [ ] Explain authorization integration
|
||||
- [ ] Add complete example module
|
||||
- **Estimated effort:** 3-4 hours
|
||||
- [x] **Guide: Creating Admin Panels** - Step-by-step guide
|
||||
- [x] Document menu registration
|
||||
- [x] Show modal creation examples
|
||||
- [x] Explain authorization integration
|
||||
- [x] Add complete example module
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/admin/creating-admin-panels.md`
|
||||
|
||||
- [ ] **Guide: HLCRF Deep Dive** - Advanced layout patterns
|
||||
- [ ] Document all layout combinations
|
||||
- [ ] Show responsive design patterns
|
||||
- [ ] Explain ID system in detail
|
||||
- [ ] Add complex real-world examples
|
||||
- **Estimated effort:** 4-5 hours
|
||||
- [x] **Guide: HLCRF Deep Dive** - Advanced layout patterns
|
||||
- [x] Document all layout combinations
|
||||
- [x] Show responsive design patterns
|
||||
- [x] Explain ID system in detail
|
||||
- [x] Add complex real-world examples
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/admin/hlcrf-deep-dive.md`
|
||||
|
||||
- [ ] **API Reference: Components** - Component prop documentation
|
||||
- [ ] Document all form component props
|
||||
- [ ] Add prop validation rules
|
||||
- [ ] Show authorization prop examples
|
||||
- [ ] Include accessibility notes
|
||||
- **Estimated effort:** 3-4 hours
|
||||
- [x] **API Reference: Components** - Component prop documentation
|
||||
- [x] Document all form component props
|
||||
- [x] Add prop validation rules
|
||||
- [x] Show authorization prop examples
|
||||
- [x] Include accessibility notes
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/admin/components-reference.md`
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
|
@ -217,5 +220,8 @@
|
|||
- [x] **Search: Provider System** - Global search with multiple providers
|
||||
- [x] **Search: Analytics** - Track search queries and results
|
||||
- [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.*
|
||||
|
|
|
|||
|
|
@ -172,26 +172,29 @@
|
|||
|
||||
## Documentation
|
||||
|
||||
- [ ] **Guide: Building REST APIs** - Complete tutorial
|
||||
- [ ] Document resource creation
|
||||
- [ ] Show pagination best practices
|
||||
- [ ] Explain filtering and sorting
|
||||
- [ ] Add authentication examples
|
||||
- **Estimated effort:** 4-5 hours
|
||||
- [x] **Guide: Building REST APIs** - Complete tutorial
|
||||
- [x] Document resource creation
|
||||
- [x] Show pagination best practices
|
||||
- [x] Explain filtering and sorting
|
||||
- [x] Add authentication examples
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/api/building-rest-apis.md`
|
||||
|
||||
- [ ] **Guide: Webhook Integration** - For API consumers
|
||||
- [ ] Document signature verification
|
||||
- [ ] Show retry handling
|
||||
- [ ] Explain event types
|
||||
- [ ] Add code examples (PHP, JS, Python)
|
||||
- **Estimated effort:** 3-4 hours
|
||||
- [x] **Guide: Webhook Integration** - For API consumers
|
||||
- [x] Document signature verification
|
||||
- [x] Show retry handling
|
||||
- [x] Explain event types
|
||||
- [x] Add code examples (PHP, JS, Python)
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/api/webhook-integration.md`
|
||||
|
||||
- [ ] **API Reference: All Endpoints** - Complete OpenAPI spec
|
||||
- [ ] Document all request parameters
|
||||
- [ ] Add response examples
|
||||
- [ ] Show error responses
|
||||
- [ ] Include authentication notes
|
||||
- **Estimated effort:** 6-8 hours
|
||||
- [x] **API Reference: All Endpoints** - Complete OpenAPI spec
|
||||
- [x] Document all request parameters
|
||||
- [x] Add response examples
|
||||
- [x] Show error responses
|
||||
- [x] Include authentication notes
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/api/endpoints-reference.md`
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
|
|
|||
|
|
@ -211,27 +211,30 @@
|
|||
|
||||
## Documentation
|
||||
|
||||
- [ ] **Guide: Creating MCP Tools** - Comprehensive tutorial
|
||||
- [ ] Document tool interface
|
||||
- [ ] Show parameter validation
|
||||
- [ ] Explain workspace context
|
||||
- [ ] Add dependency examples
|
||||
- [ ] Include security best practices
|
||||
- **Estimated effort:** 4-5 hours
|
||||
- [x] **Guide: Creating MCP Tools** - Comprehensive tutorial
|
||||
- [x] Document tool interface
|
||||
- [x] Show parameter validation
|
||||
- [x] Explain workspace context
|
||||
- [x] Add dependency examples
|
||||
- [x] Include security best practices
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/mcp/creating-mcp-tools.md`
|
||||
|
||||
- [ ] **Guide: SQL Security** - Safe query patterns
|
||||
- [ ] Document allowed SQL patterns
|
||||
- [ ] Show parameterized query examples
|
||||
- [ ] Explain validation rules
|
||||
- [ ] List forbidden operations
|
||||
- **Estimated effort:** 3-4 hours
|
||||
- [x] **Guide: SQL Security** - Safe query patterns
|
||||
- [x] Document allowed SQL patterns
|
||||
- [x] Show parameterized query examples
|
||||
- [x] Explain validation rules
|
||||
- [x] List forbidden operations
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/mcp/sql-security.md`
|
||||
|
||||
- [ ] **API Reference: All MCP Tools** - Complete tool catalog
|
||||
- [ ] Document each tool's parameters
|
||||
- [ ] Add usage examples
|
||||
- [ ] Show response formats
|
||||
- [ ] Include error cases
|
||||
- **Estimated effort:** 5-6 hours
|
||||
- [x] **API Reference: All MCP Tools** - Complete tool catalog
|
||||
- [x] Document each tool's parameters
|
||||
- [x] Add usage examples
|
||||
- [x] Show response formats
|
||||
- [x] Include error cases
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/mcp/tools-reference.md`
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
|
@ -295,6 +298,8 @@
|
|||
- [x] **Tool Analytics System** - Complete usage tracking and metrics
|
||||
- [x] **Quota System** - Tier-based limits with enforcement
|
||||
- [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.*
|
||||
|
|
|
|||
|
|
@ -266,17 +266,19 @@
|
|||
|
||||
## Documentation
|
||||
|
||||
- [ ] **API Docs: Service Contracts** - Document service pattern
|
||||
- [ ] Add examples for ServiceDefinition
|
||||
- [ ] Document service versioning
|
||||
- [ ] Add dependency resolution examples
|
||||
- **Estimated effort:** 2-3 hours
|
||||
- [x] **API Docs: Service Contracts** - Document service pattern
|
||||
- [x] Add examples for ServiceDefinition
|
||||
- [x] Document service versioning
|
||||
- [x] Add dependency resolution examples
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/core/service-contracts.md`
|
||||
|
||||
- [ ] **API Docs: Seeder System** - Document seeder attributes
|
||||
- [ ] Document dependency resolution
|
||||
- [ ] Add complex ordering examples
|
||||
- [ ] Document circular dependency errors
|
||||
- **Estimated effort:** 2-3 hours
|
||||
- [x] **API Docs: Seeder System** - Document seeder attributes
|
||||
- [x] Document dependency resolution
|
||||
- [x] Add complex ordering examples
|
||||
- [x] Document circular dependency errors
|
||||
- **Completed:** January 2026
|
||||
- **File:** `docs/packages/core/seeder-system.md`
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue