php-framework/.claude/skills/core-patterns.md

12 KiB

name description
core-patterns Scaffold Core PHP Framework patterns (Actions, Multi-tenant, Activity Logging, Modules, Seeders)

Core Patterns Scaffolding

You are helping the user scaffold common Core PHP Framework patterns. This is an interactive skill - gather information through conversation before generating code.

Start by asking what the user wants to create

Present these options:

  1. Action class - Single-purpose business logic class
  2. Multi-tenant model - Add workspace isolation to a model
  3. Activity logging - Add change tracking to a model
  4. Module - Create a new module with Boot class
  5. Seeder - Create a seeder with dependency ordering

Ask: "What would you like to scaffold? (1-5 or describe what you need)"


Option 1: Action Class

Actions are small, focused classes that do one thing well. They extract complex logic from controllers and Livewire components.

Gather information

Ask the user for:

  • Action name (e.g., CreateInvoice, PublishPost, SendNotification)
  • Module (e.g., Billing, Content, Notification)
  • What it does (brief description to understand parameters needed)

Generate the Action

Location: packages/core-php/src/Mod/{Module}/Actions/{ActionName}.php

<?php

declare(strict_types=1);

namespace Core\Mod\{Module}\Actions;

use Core\Actions\Action;

/**
 * {Description}
 *
 * Usage:
 *   $action = app({ActionName}::class);
 *   $result = $action->handle($param1, $param2);
 *
 *   // Or via static helper:
 *   $result = {ActionName}::run($param1, $param2);
 */
class {ActionName}
{
    use Action;

    public function __construct(
        // Inject dependencies here
    ) {}

    /**
     * Execute the action.
     */
    public function handle(/* parameters */): mixed
    {
        // Implementation
    }
}

Key points to explain

  • Actions use the Core\Actions\Action trait for the static run() helper
  • Dependencies are constructor-injected
  • The handle() method contains the business logic
  • Can optionally implement Core\Actions\Actionable for type-hinting
  • Naming convention: verb + noun (CreateThing, UpdateThing, DeleteThing)

Option 2: Multi-tenant Model

The BelongsToWorkspace trait enforces workspace isolation with automatic scoping and caching.

Gather information

Ask the user for:

  • Model name (e.g., Invoice, Project)
  • Whether workspace context is always required (default: yes)

Migration requirement

Ensure the model's table has a workspace_id column:

$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();

Add the trait

<?php

declare(strict_types=1);

namespace Core\Mod\{Module}\Models;

use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Model;

class {ModelName} extends Model
{
    use BelongsToWorkspace;

    protected $fillable = [
        'workspace_id',
        // other fields...
    ];

    // Optional: Disable strict mode (not recommended)
    // protected bool $workspaceContextRequired = false;
}

Key points to explain

  • Auto-assignment: workspace_id is automatically set from the current workspace context on create
  • Query scoping: Use Model::ownedByCurrentWorkspace() to scope queries
  • Caching: Use Model::ownedByCurrentWorkspaceCached() for cached collections
  • Security: Throws MissingWorkspaceContextException if no workspace context and strict mode is enabled
  • Relation: Provides workspace() belongsTo relationship

Usage examples

// Query scoped to current workspace
$invoices = Invoice::ownedByCurrentWorkspace()->where('status', 'paid')->get();

// Cached collection for current workspace
$invoices = Invoice::ownedByCurrentWorkspaceCached();

// Query for specific workspace
$invoices = Invoice::forWorkspace($workspace)->get();

// Check ownership
if ($invoice->belongsToCurrentWorkspace()) {
    // safe to display
}

Option 3: Activity Logging

The LogsActivity trait wraps spatie/laravel-activitylog with framework defaults and workspace tagging.

Gather information

Ask the user for:

  • Model name to add logging to
  • Which attributes to log (all, or specific ones)
  • Which events to log (created, updated, deleted - default: all)

Add the trait

<?php

declare(strict_types=1);

namespace Core\Mod\{Module}\Models;

use Core\Activity\Concerns\LogsActivity;
use Illuminate\Database\Eloquent\Model;

class {ModelName} extends Model
{
    use LogsActivity;

    // Optional configuration via properties:

    // Log only specific attributes (default: all)
    // protected array $activityLogAttributes = ['status', 'amount'];

    // Custom log name (default: from config)
    // protected string $activityLogName = 'invoices';

    // Events to log (default: created, updated, deleted)
    // protected array $activityLogEvents = ['created', 'updated'];

    // Include workspace_id in properties (default: true)
    // protected bool $activityLogWorkspace = true;

    // Only log dirty attributes (default: true)
    // protected bool $activityLogOnlyDirty = true;
}

Custom activity tap (optional)

/**
 * Customize activity before saving.
 */
protected function customizeActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
{
    $activity->properties = $activity->properties->merge([
        'custom_field' => $this->some_field,
    ]);
}

Key points to explain

  • Automatically includes workspace_id in activity properties
  • Empty logs are not submitted
  • Uses sensible defaults that can be overridden via model properties
  • Can temporarily disable logging with Model::withoutActivityLogging(fn() => ...)

Option 4: Module

Modules are the core organizational unit. Each module has a Boot class that declares which lifecycle events it listens to.

Gather information

Ask the user for:

  • Module name (e.g., Billing, Notifications)
  • What the module provides (web routes, admin panel, API, console commands)

Create the directory structure

packages/core-php/src/Mod/{ModuleName}/
├── Boot.php                    # Module entry point
├── Models/                     # Eloquent models
├── Actions/                    # Business logic
├── Routes/
│   ├── web.php                # Web routes
│   └── api.php                # API routes
├── View/
│   └── Blade/                 # Blade views
├── Console/                   # Artisan commands
├── Database/
│   ├── Migrations/            # Database migrations
│   └── Seeders/               # Database seeders
└── Lang/
    └── en_GB/                 # Translations

Generate Boot.php

<?php

declare(strict_types=1);

namespace Core\Mod\{ModuleName};

use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\WebRoutesRegistering;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;

/**
 * {ModuleName} Module Boot.
 *
 * {Description of what this module handles}
 */
class Boot extends ServiceProvider
{
    protected string $moduleName = '{module_slug}';

    /**
     * Events this module listens to for lazy loading.
     *
     * @var array<class-string, string>
     */
    public static array $listens = [
        WebRoutesRegistering::class => 'onWebRoutes',
        ApiRoutesRegistering::class => 'onApiRoutes',
        AdminPanelBooting::class => 'onAdminPanel',
        ConsoleBooting::class => 'onConsole',
    ];

    public function register(): void
    {
        // Register singletons and bindings
    }

    public function boot(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/Database/Migrations');
        $this->loadTranslationsFrom(__DIR__.'/Lang/en_GB', $this->moduleName);
    }

    // -------------------------------------------------------------------------
    // Event-driven handlers
    // -------------------------------------------------------------------------

    public function onWebRoutes(WebRoutesRegistering $event): void
    {
        $event->views($this->moduleName, __DIR__.'/View/Blade');

        if (file_exists(__DIR__.'/Routes/web.php')) {
            $event->routes(fn () => Route::middleware('web')->group(__DIR__.'/Routes/web.php'));
        }

        // Register Livewire components
        // $event->livewire('{module}.component-name', View\Components\ComponentName::class);
    }

    public function onApiRoutes(ApiRoutesRegistering $event): void
    {
        if (file_exists(__DIR__.'/Routes/api.php')) {
            $event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php'));
        }
    }

    public function onAdminPanel(AdminPanelBooting $event): void
    {
        $event->views($this->moduleName, __DIR__.'/View/Blade');
    }

    public function onConsole(ConsoleBooting $event): void
    {
        // Register commands
        // $event->command(Console\MyCommand::class);
    }
}

Available lifecycle events

Event Purpose Handler receives
WebRoutesRegistering Public web routes views, routes, livewire
AdminPanelBooting Admin panel setup views, routes
ApiRoutesRegistering REST API routes routes
ClientRoutesRegistering Authenticated client routes routes
ConsoleBooting Artisan commands command, middleware
McpToolsRegistering MCP tools tools
FrameworkBooted Late initialization -

Key points to explain

  • The $listens array declares which events trigger which methods
  • Modules are lazy-loaded - only instantiated when their events fire
  • Keep Boot classes thin - delegate to services and actions
  • Use the $moduleName for consistent view namespace and translations

Option 5: Seeder with Dependencies

Seeders can declare ordering via attributes for dependencies between seeders.

Gather information

Ask the user for:

  • Seeder name (e.g., PackageSeeder, DemoDataSeeder)
  • Module it belongs to
  • Dependencies - which seeders must run before this one
  • Priority (optional) - lower numbers run first (default: 50)

Generate the Seeder

Location: packages/core-php/src/Mod/{Module}/Database/Seeders/{SeederName}.php

<?php

declare(strict_types=1);

namespace Core\Mod\{Module}\Database\Seeders;

use Core\Database\Seeders\Attributes\SeederAfter;
use Core\Database\Seeders\Attributes\SeederPriority;
use Core\Mod\Tenant\Database\Seeders\FeatureSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;

/**
 * Seeds {description}.
 */
#[SeederPriority(50)]
#[SeederAfter(FeatureSeeder::class)]
class {SeederName} extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        // Guard against missing tables
        if (! Schema::hasTable('your_table')) {
            return;
        }

        // Seeding logic here
    }
}

Available attributes

// Set priority (lower runs first, default 50)
#[SeederPriority(10)]

// Must run after these seeders
#[SeederAfter(FeatureSeeder::class)]
#[SeederAfter(FeatureSeeder::class, PackageSeeder::class)]

// Must run before these seeders
#[SeederBefore(DemoDataSeeder::class)]

Priority guidelines

Range Use case
0-20 Foundation seeders (features, configuration)
20-40 Core data (packages, workspaces)
40-60 Default priority (general seeders)
60-80 Content seeders (pages, posts)
80-100 Demo/test data seeders

Key points to explain

  • Always guard against missing tables with Schema::hasTable()
  • Use updateOrCreate() to make seeders idempotent
  • Seeders are auto-discovered from Database/Seeders/ directories
  • The framework detects circular dependencies and throws CircularDependencyException

After generating code

Always:

  1. Show the generated code with proper file paths
  2. Explain what was created and why
  3. Provide usage examples
  4. Mention any follow-up steps (migrations, route registration, etc.)
  5. Ask if they need any modifications or have questions

Remember: This is pair programming. Be helpful, explain decisions, and adapt to what the user needs.