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:
Snider 2026-01-26 18:22:50 +00:00
parent 62c23b7fe9
commit 7631afb12e
17 changed files with 8708 additions and 106 deletions

43
TODO.md
View file

@ -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/`

View file

@ -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' }
]
}
],

View 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)

View 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)

View 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">&larr; Previous: Setup</a>
</div>
<div>
<a href="/next" class="text-blue-600">Next: Installation &rarr;</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 &lt;john@example.com&gt;</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)

View 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

File diff suppressed because it is too large Load diff

View 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

View 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)

View 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)

View 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

View 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

View 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

View file

@ -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.*

View file

@ -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

View file

@ -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.*

View file

@ -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