diff --git a/.claude/skills/core-patterns.md b/.claude/skills/core-patterns.md new file mode 100644 index 0000000..05dbb94 --- /dev/null +++ b/.claude/skills/core-patterns.md @@ -0,0 +1,455 @@ +--- +name: core-patterns +description: 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 +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: + +```php +$table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); +``` + +### Add the trait + +```php +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 +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 + + */ + 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 +/dev/null; then - for path in $(php -r " - \$d = json_decode(file_get_contents('composer.json'), true); - foreach (\$d['repositories'] ?? [] as \$r) { - if ((\$r['type'] ?? '') === 'path') echo \$r['url'] . \"\\n\"; - } - "); do - dir_name=$(basename "$path") - if [ ! -d "$path" ]; then - echo "Cloning $dir_name into $path" - git clone --depth 1 \ - "https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/${dir_name}.git" \ - "$path" || echo "Warning: Failed to clone $dir_name" - fi - done - fi - - - name: Install dependencies - run: composer install --prefer-dist --no-interaction --no-progress - - - name: Run Pint - if: inputs.pint - run: | - if [ -f vendor/bin/pint ]; then - vendor/bin/pint --test - else - echo "Pint not installed, skipping" - fi - - - name: Run tests - run: | - if [ -f vendor/bin/pest ]; then - FLAGS="--ci" - if [ "${{ inputs.coverage }}" = "true" ]; then - FLAGS="$FLAGS --coverage" - fi - vendor/bin/pest $FLAGS - elif [ -f vendor/bin/phpunit ]; then - vendor/bin/phpunit - else - echo "No test runner found (pest or phpunit), skipping tests" - fi diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..844f7a2 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,38 @@ +name: Publish Composer Package + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create package archive + run: | + apt-get update && apt-get install -y zip + zip -r package.zip . \ + -x ".forgejo/*" \ + -x ".git/*" \ + -x "tests/*" \ + -x "docker/*" \ + -x "*.yaml" \ + -x "infection.json5" \ + -x "phpstan.neon" \ + -x "phpunit.xml" \ + -x "psalm.xml" \ + -x "rector.php" \ + -x "TODO.md" \ + -x "ROADMAP.md" \ + -x "CONTRIBUTING.md" \ + -x "package.json" \ + -x "package-lock.json" + + - name: Publish to Forgejo Composer registry + run: | + curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \ + --upload-file package.zip \ + "https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}" diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c42fff9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,92 @@ +name: Bug Report +description: Report a bug or unexpected behavior +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the form below. + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug + placeholder: What happened? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + placeholder: What should have happened? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened + placeholder: What actually happened? + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: Information about your environment + value: | + - Core PHP Version: + - PHP Version: + - Laravel Version: + - Database: + - OS: + render: markdown + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error Logs + description: Relevant error logs or stack traces + render: shell + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context about the problem + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this is not a duplicate + required: true + - label: I have provided all requested information + required: true + - label: I am using a supported version of Core PHP + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3f64c2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,91 @@ +name: Feature Request +description: Suggest a new feature or enhancement +labels: ["enhancement", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please provide details below. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem? + placeholder: I'm frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like + placeholder: I would like... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Describe alternatives you've considered + placeholder: I also considered... + validations: + required: false + + - type: textarea + id: examples + attributes: + label: Code Examples + description: Provide code examples if applicable + render: php + validations: + required: false + + - type: dropdown + id: package + attributes: + label: Affected Package + description: Which package does this feature relate to? + options: + - Core + - Admin + - API + - MCP + - Multiple packages + - Not sure + validations: + required: true + + - type: dropdown + id: breaking + attributes: + label: Breaking Change + description: Would this be a breaking change? + options: + - "No" + - "Yes" + - "Not sure" + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context or screenshots + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this is not a duplicate + required: true + - label: I have considered backwards compatibility + required: true + - label: This feature aligns with the project's goals + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b74ba7a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,68 @@ +# Pull Request + +## Description + +Please provide a clear description of your changes and the motivation behind them. + +Fixes # (issue) + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring +- [ ] Test improvements + +## Testing + +Please describe the tests you ran to verify your changes: + +- [ ] Test A +- [ ] Test B + +**Test Configuration:** +- PHP Version: +- Laravel Version: +- Database: + +## Checklist + +- [ ] My code follows the project's coding standards (PSR-12) +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings or errors +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published +- [ ] I have updated the CHANGELOG.md file +- [ ] I have checked my code for security vulnerabilities + +## Screenshots (if applicable) + +Add screenshots to help explain your changes. + +## Breaking Changes + +If this PR introduces breaking changes, please describe: + +1. What breaks: +2. Why it's necessary: +3. Migration path: + +## Additional Notes + +Add any other context about the pull request here. + +--- + +**For Maintainers:** + +- [ ] Code reviewed +- [ ] Tests passing +- [ ] Documentation updated +- [ ] Changelog updated +- [ ] Ready to merge diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..34b5e65 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,51 @@ +name: Code Style + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + pint: + name: Laravel Pint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run Laravel Pint + run: vendor/bin/pint --test + + phpcs: + name: PHP CodeSniffer + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHP CodeSniffer + run: vendor/bin/phpcs --standard=PSR12 packages/*/src + continue-on-error: true diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..5d82a4f --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,63 @@ +name: Deploy Documentation + +on: + push: + branches: [ main ] + paths: + - 'docs/**' + - '.github/workflows/deploy-docs.yml' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for .lastUpdated + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Install dependencies + run: npm ci + + - name: Build with VitePress + run: npm run docs:build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + name: Deploy + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..da7d4d5 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,93 @@ +name: Static Analysis + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --memory-limit=2G + + psalm: + name: Psalm + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run Psalm + run: vendor/bin/psalm --show-info=false + + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Security audit + run: composer audit + + lint: + name: PHP Syntax Check + runs-on: ubuntu-latest + + strategy: + matrix: + php: ['8.2', '8.3'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Check PHP syntax + run: find . -name "*.php" -not -path "./vendor/*" -print0 | xargs -0 -n1 php -l diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..dd6d565 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,51 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: [8.2, 8.3, 8.4] + laravel: [11.*, 12.*] + exclude: + - php: 8.2 + laravel: 12.* + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip + coverage: xdebug + + - name: Install dependencies + env: + LARAVEL_VERSION: ${{ matrix.laravel }} + run: | + composer require "laravel/framework:${LARAVEL_VERSION}" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests with coverage + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload coverage to Codecov + if: matrix.php == '8.3' && matrix.laravel == '11.*' + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + fail_ci_if_error: false + verbose: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70000de --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +/vendor +/packages/*/vendor +composer.lock +.DS_Store +.idea/ +*.swp +*.swo +.env +.env.dev +auth.json +node_modules/ +bootstrap/cache +public/build +/storage/*.key +/storage/pail +/storage/logs +/storage/framework +.phpunit.result.cache +.phpunit.cache +/coverage +/docs/.vitepress/dist +docs/.vitepress/cache/ + +# QA tools +.infection/ +infection.log +infection-summary.log +.rector-cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2081074 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +composer test # Run all tests (PHPUnit) +composer test -- --filter=Name # Run single test by name +composer test -- --testsuite=Unit # Run specific test suite +composer pint # Format code with Laravel Pint +./vendor/bin/pint --dirty # Format only changed files +``` + +## Coding Standards + +- **UK English**: colour, organisation, centre (never American spellings) +- **Strict types**: `declare(strict_types=1);` in every PHP file +- **Type hints**: All parameters and return types required +- **Testing**: PHPUnit with Orchestra Testbench +- **License**: EUPL-1.2 + +## Architecture + +### Event-Driven Module Loading + +Modules declare interest in lifecycle events via static `$listens` arrays and are only instantiated when those events fire: + +``` +LifecycleEventProvider::register() + └── ModuleScanner::scan() # Finds Boot.php files with $listens + └── ModuleRegistry::register() # Wires LazyModuleListener for each event +``` + +**Key benefit**: Web requests don't load admin modules; API requests don't load web modules. + +### Frontages + +Frontages are ServiceProviders in `src/Core/Front/` that fire context-specific lifecycle events: + +| Frontage | Event | Middleware | Fires When | +|----------|-------|------------|------------| +| Web | `WebRoutesRegistering` | `web` | Public routes | +| Admin | `AdminPanelBooting` | `admin` | Admin panel | +| Api | `ApiRoutesRegistering` | `api` | REST endpoints | +| Client | `ClientRoutesRegistering` | `client` | Authenticated SaaS | +| Cli | `ConsoleBooting` | - | Artisan commands | +| Mcp | `McpToolsRegistering` | - | MCP tool handlers | +| - | `FrameworkBooted` | - | Late-stage initialisation | + +### L1 Packages + +Subdirectories under `src/Core/` are self-contained "L1 packages" with their own Boot.php, migrations, tests, and views: + +``` +src/Core/Activity/ # Activity logging (wraps spatie/laravel-activitylog) +src/Core/Bouncer/ # Security blocking/redirects +src/Core/Cdn/ # CDN integration +src/Core/Config/ # Dynamic configuration +src/Core/Front/ # Frontage system (Web, Admin, Api, Client, Cli, Mcp) +src/Core/Lang/ # Translation system +src/Core/Media/ # Media handling with thumbnail helpers +src/Core/Search/ # Search functionality +src/Core/Seo/ # SEO utilities +``` + +### Module Pattern + +```php +class Boot +{ + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + AdminPanelBooting::class => ['onAdmin', 10], // With priority + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('example', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + $event->livewire('example.widget', ExampleWidget::class); + } +} +``` + +### Namespace Mapping + +| Path | Namespace | +|------|-----------| +| `src/Core/` | `Core\` | +| `src/Mod/` | `Core\Mod\` | +| `src/Plug/` | `Core\Plug\` | +| `src/Website/` | `Core\Website\` | +| `app/Mod/` | `Mod\` | + +### Actions Pattern + +Single-purpose business logic classes with static `run()` helper: + +```php +use Core\Actions\Action; + +class CreateOrder +{ + use Action; + + public function __construct(private OrderService $orders) {} + + public function handle(User $user, array $data): Order + { + return $this->orders->create($user, $data); + } +} + +// Usage: CreateOrder::run($user, $validated); +``` + +### Seeder Ordering + +Seeders use PHP attributes for dependency ordering: + +```php +use Core\Database\Seeders\Attributes\SeederPriority; +use Core\Database\Seeders\Attributes\SeederAfter; + +#[SeederPriority(50)] // Lower runs first (default 50) +#[SeederAfter(FeatureSeeder::class)] +class PackageSeeder extends Seeder +{ + public function run(): void + { + if (! Schema::hasTable('packages')) return; // Guard missing tables + // ... + } +} +``` + +### HLCRF Layout System + +Data-driven layouts with five regions (Header, Left, Content, Right, Footer): + +```php +use Core\Front\Components\Layout; + +$page = Layout::make('HCF') // Variant: Header-Content-Footer + ->h(view('header')) + ->c($content) + ->f(view('footer')); +``` + +Variant strings: `C` (content only), `HCF` (standard page), `HLCF` (with sidebar), `HLCRF` (full dashboard). + +## Testing + +Uses Orchestra Testbench with in-memory SQLite. Tests can live: +- `tests/Feature/` and `tests/Unit/` - main test suites +- `src/Core/{Package}/Tests/` - L1 package co-located tests +- `src/Mod/{Module}/Tests/` - module co-located tests + +Test fixtures are in `tests/Fixtures/`. + +Base test class provides: +```php +$this->getFixturePath('Mod') // Returns tests/Fixtures/Mod path +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1487778 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,287 @@ +# Contributing to Core PHP Framework + +Thank you for considering contributing to the Core PHP Framework! This document outlines the process and guidelines for contributing. + +## Code of Conduct + +This project adheres to a code of conduct that all contributors are expected to follow. Be respectful, professional, and inclusive in all interactions. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the existing issues to avoid duplicates. When creating a bug report, include: + +- **Clear title and description** +- **Steps to reproduce** the behavior +- **Expected vs actual behavior** +- **PHP and Laravel versions** +- **Code samples** if applicable +- **Error messages** and stack traces + +### Security Vulnerabilities + +**DO NOT** open public issues for security vulnerabilities. Instead, email security concerns to: **support@host.uk.com** + +We take security seriously and will respond promptly to valid security reports. + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion: + +- **Use a clear and descriptive title** +- **Provide a detailed description** of the proposed feature +- **Explain why this enhancement would be useful** to most users +- **List similar features** in other frameworks if applicable + +### Pull Requests + +1. **Fork the repository** and create your branch from `main` +2. **Follow the coding standards** (see below) +3. **Add tests** for any new functionality +4. **Update documentation** as needed +5. **Ensure all tests pass** before submitting +6. **Write clear commit messages** (see below) + +## Development Setup + +### Prerequisites + +- PHP 8.2 or higher +- Composer +- Laravel 11 or 12 + +### Setup Steps + +```bash +# Clone your fork +git clone https://github.com/your-username/core-php.git +cd core-php + +# Install dependencies +composer install + +# Copy environment file +cp .env.example .env + +# Generate application key +php artisan key:generate + +# Run migrations +php artisan migrate + +# Run tests +composer test +``` + +## Coding Standards + +### PSR Standards + +- Follow **PSR-12** coding style +- Use **PSR-4** autoloading + +### Laravel Conventions + +- Use **Laravel's naming conventions** for classes, methods, and variables +- Follow **Laravel's directory structure** patterns +- Use **Eloquent** for database interactions where appropriate + +### Code Style + +We use **Laravel Pint** for code formatting: + +```bash +./vendor/bin/pint +``` + +Run this before committing to ensure consistent code style. + +### PHP Standards + +- Use **strict typing**: `declare(strict_types=1);` +- Add **type hints** for all method parameters and return types +- Use **short array syntax**: `[]` instead of `array()` +- Document complex logic with clear comments +- Avoid abbreviations in variable/method names + +### Testing + +- Write **feature tests** for new functionality +- Write **unit tests** for complex business logic +- Aim for **> 70% code coverage** +- Use **meaningful test names** that describe what is being tested + +```php +public function test_user_can_create_workspace_with_valid_data(): void +{ + // Test implementation +} +``` + +## Commit Message Guidelines + +### Format + +``` +type(scope): subject + +body (optional) + +footer (optional) +``` + +### Types + +- **feat**: New feature +- **fix**: Bug fix +- **docs**: Documentation changes +- **style**: Code style changes (formatting, semicolons, etc.) +- **refactor**: Code refactoring without feature changes +- **test**: Adding or updating tests +- **chore**: Maintenance tasks + +### Examples + +``` +feat(modules): add lazy loading for API modules + +Implement lazy loading system that only loads API modules +when API routes are being registered, improving performance +for web-only requests. + +Closes #123 +``` + +``` +fix(auth): resolve session timeout issue + +Fix session expiration not being properly handled in multi-tenant +environment. + +Fixes #456 +``` + +### Rules + +- Use **present tense**: "add feature" not "added feature" +- Use **imperative mood**: "move cursor to..." not "moves cursor to..." +- Keep **subject line under 72 characters** +- Reference **issue numbers** when applicable +- **Separate subject from body** with a blank line + +## Package Development + +### Creating a New Package + +New packages should follow this structure: + +``` +packages/ +└── package-name/ + ├── src/ + ├── tests/ + ├── composer.json + ├── README.md + └── LICENSE +``` + +### Package Guidelines + +- Each package should have a **clear, single purpose** +- Include **comprehensive tests** +- Add a **detailed README** with usage examples +- Follow **semantic versioning** +- Document all **public APIs** + +## Testing Guidelines + +### Running Tests + +```bash +# Run all tests +composer test + +# Run specific test suite +./vendor/bin/phpunit --testsuite=Feature + +# Run specific test file +./vendor/bin/phpunit tests/Feature/ModuleSystemTest.php + +# Run with coverage +./vendor/bin/phpunit --coverage-html coverage +``` + +### Test Organization + +- **Feature tests**: Test complete features end-to-end +- **Unit tests**: Test individual classes/methods in isolation +- **Integration tests**: Test interactions between components + +### Test Best Practices + +- Use **factories** for creating test data +- Use **database transactions** to keep tests isolated +- **Mock external services** to avoid network calls +- Test **edge cases** and error conditions +- Keep tests **fast** and **deterministic** + +## Documentation + +### Code Documentation + +- Add **PHPDoc blocks** for all public methods +- Document **complex algorithms** with inline comments +- Include **usage examples** in docblocks for key classes +- Keep documentation **up-to-date** with code changes + +### Example PHPDoc + +```php +/** + * Create a new workspace with the given attributes. + * + * This method handles workspace creation including: + * - Validation of input data + * - Creation of default settings + * - Assignment of owner permissions + * + * @param array $attributes Workspace attributes (name, slug, settings) + * @return \Core\Mod\Tenant\Models\Workspace + * @throws \Illuminate\Validation\ValidationException + */ +public function create(array $attributes): Workspace +{ + // Implementation +} +``` + +## Review Process + +### What We Look For + +- **Code quality**: Clean, readable, maintainable code +- **Tests**: Adequate test coverage for new code +- **Documentation**: Clear documentation for new features +- **Performance**: No significant performance regressions +- **Security**: No security vulnerabilities introduced + +### Timeline + +- Initial review typically within **1-3 business days** +- Follow-up reviews within **1 business day** +- Complex PRs may require additional review time + +## License + +By contributing to the Core PHP Framework, you agree that your contributions will be licensed under the **EUPL-1.2** license. + +## Questions? + +If you have questions about contributing, feel free to: + +- Open a **GitHub Discussion** +- Create an **issue** labeled "question" +- Email **support@host.uk.com** + +Thank you for contributing! 🎉 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba61a9d --- /dev/null +++ b/LICENSE @@ -0,0 +1,287 @@ +EUROPEAN UNION PUBLIC LICENCE v. 1.2 +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the 'EUPL') applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + +Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- 'The Licence': this Licence. + +- 'The Original Work': the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- 'Derivative Works': the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- 'The Work': the Original Work or its Derivative Works. + +- 'The Source Code': the human-readable form of the Work which is the most + convenient for people to study and modify. + +- 'The Executable Code': any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- 'The Licensor': the natural or legal person that distributes or communicates + the Work under the Licence. + +- 'Contributor(s)': any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- 'The Licensee' or 'You': any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- 'Distribution' or 'Communication': any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating 'EUPL v. 1.2 only'. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, 'Compatible +Licence' refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +'bugs' inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an 'as is' basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no circumstances be liable for any direct or +indirect, material or moral, damages of any kind, arising out of the Licence or +of the use of the Work, including without limitation, damages for loss of +goodwill, work stoppage, computer failure or malfunction, loss of data or any +commercial damage, even if the Licensor has been advised of the possibility of +such damage. However, the Licensor will be liable under statutory product +liability laws as far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon 'I agree' +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +'Compatible Licences' according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..862040c --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# Core PHP Framework + +[![Tests](https://github.com/host-uk/core-php/workflows/Tests/badge.svg)](https://github.com/host-uk/core-php/actions) +[![Code Coverage](https://codecov.io/gh/host-uk/core-php/branch/main/graph/badge.svg)](https://codecov.io/gh/host-uk/core-php) +[![Latest Stable Version](https://poser.pugx.org/host-uk/core/v/stable)](https://packagist.org/packages/host-uk/core) +[![License](https://img.shields.io/badge/license-EUPL--1.2-blue.svg)](LICENSE) +[![PHP Version](https://img.shields.io/badge/php-%5E8.2-8892BF.svg)](https://php.net/) +[![Laravel Version](https://img.shields.io/badge/laravel-%5E11.0%7C%5E12.0-FF2D20.svg)](https://laravel.com) + +A modular monolith framework for Laravel with event-driven architecture, lazy module loading, and built-in multi-tenancy. + +## Documentation + +📚 **[Read the full documentation →](https://core.help/)** + +- [Getting Started](https://core.help/guide/getting-started) +- [Installation Guide](https://core.help/guide/installation) +- [Architecture Overview](https://core.help/architecture/lifecycle-events) +- [API Reference](https://core.help/packages/api) +- [Security Guide](https://core.help/security/overview) + +## Features + +- **Event-driven module system** - Modules declare interest in lifecycle events and are only loaded when needed +- **Lazy loading** - Web requests don't load admin modules, API requests don't load web modules +- **Multi-tenant isolation** - Workspace-scoped data with automatic query filtering +- **Actions pattern** - Single-purpose business logic classes with dependency injection +- **Activity logging** - Built-in audit trails for model changes +- **Seeder auto-discovery** - Automatic ordering via priority and dependency attributes +- **HLCRF Layout System** - Hierarchical composable layouts (Header, Left, Content, Right, Footer) + +## Installation + +```bash +composer require host-uk/core +``` + +The service provider will be auto-discovered. + +## Quick Start + +### Creating a Module + +```bash +php artisan make:mod Commerce +``` + +This creates a module at `app/Mod/Commerce/` with a `Boot.php` entry point: + +```php + 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('commerce', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + + public function onAdmin(AdminPanelBooting $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } +} +``` + +### Lifecycle Events + +| Event | Purpose | +|-------|---------| +| `WebRoutesRegistering` | Public-facing web routes | +| `AdminPanelBooting` | Admin panel routes and navigation | +| `ApiRoutesRegistering` | REST API endpoints | +| `ClientRoutesRegistering` | Authenticated client routes | +| `ConsoleBooting` | Artisan commands | +| `McpToolsRegistering` | MCP tool handlers | +| `FrameworkBooted` | Late-stage initialisation | + +## Core Patterns + +### Actions + +Extract business logic into testable, reusable classes: + +```php +use Core\Actions\Action; + +class CreateOrder +{ + use Action; + + public function handle(User $user, array $data): Order + { + // Business logic here + return Order::create($data); + } +} + +// Usage +$order = CreateOrder::run($user, $validated); +``` + +### Multi-Tenant Isolation + +Automatic workspace scoping for models: + +```php +use Core\Mod\Tenant\Concerns\BelongsToWorkspace; + +class Product extends Model +{ + use BelongsToWorkspace; +} + +// Queries are automatically scoped to the current workspace +$products = Product::all(); + +// workspace_id is auto-assigned on create +$product = Product::create(['name' => 'Widget']); +``` + +### Activity Logging + +Track model changes with minimal setup: + +```php +use Core\Activity\Concerns\LogsActivity; + +class Order extends Model +{ + use LogsActivity; + + protected array $activityLogAttributes = ['status', 'total']; +} +``` + +### HLCRF Layout System + +Data-driven layouts with infinite nesting: + +```php +use Core\Front\Components\Layout; + +$page = Layout::make('HCF') + ->h('') + ->c('
Main content
') + ->f(''); + +echo $page; +``` + +Variant strings define structure: `HCF` (Header-Content-Footer), `HLCRF` (all five regions), `H[LC]CF` (nested layouts). + +See [HLCRF.md](packages/core-php/src/Core/Front/HLCRF.md) for full documentation. + +## Configuration + +Publish the config file: + +```bash +php artisan vendor:publish --tag=core-config +``` + +Configure module paths in `config/core.php`: + +```php +return [ + 'module_paths' => [ + app_path('Core'), + app_path('Mod'), + ], +]; +``` + +## Artisan Commands + +```bash +php artisan make:mod Commerce # Create a module +php artisan make:website Marketing # Create a website module +php artisan make:plug Stripe # Create a plugin +``` + +## Module Structure + +``` +app/Mod/Commerce/ +├── Boot.php # Module entry point +├── Actions/ # Business logic +├── Models/ # Eloquent models +├── Routes/ +│ ├── web.php +│ ├── admin.php +│ └── api.php +├── Views/ +├── Migrations/ +└── config.php +``` + +## Documentation + +- [Patterns Guide](docs/patterns.md) - Detailed documentation for all framework patterns +- [HLCRF Layout System](packages/core-php/src/Core/Front/HLCRF.md) - Composable layout documentation + +## Testing + +```bash +composer test +``` + +## Requirements + +- PHP 8.2+ +- Laravel 11+ + +## License + +EUPL-1.2 - See [LICENSE](LICENSE) for details. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f84b2d4 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,214 @@ +# Core PHP Framework - Roadmap + +Strategic growth plan for the EUPL-1.2 open-source framework. + +## Version 1.1 (Q2 2026) - Polish & Stability + +**Focus:** Test coverage, bug fixes, performance optimization + +### Testing +- Achieve 80%+ test coverage across all packages +- Add integration tests for CDN, Media, Search, SEO systems +- Comprehensive test suite for MCP security + +### Performance +- Benchmark and optimize critical paths +- Implement tiered caching (memory → Redis → file) +- Query optimization with eager loading audits + +### Documentation +- Add video tutorials for common patterns +- Create example modules for each pattern +- Expand HLCRF documentation with advanced layouts + +**Estimated Timeline:** 3 months + +--- + +## Version 1.2 (Q3 2026) - Developer Experience + +**Focus:** Tools and utilities for faster development + +### Admin Tools +- Data Tables component with sorting/filtering/export +- Dashboard widget system with drag-and-drop +- Notification center for in-app notifications +- File manager with media browser + +### CLI Enhancements +- Interactive module scaffolding +- Code generator for common patterns +- Database migration helper +- Deployment automation + +### Dev Tools +- Query profiler in development +- Real-time performance monitoring +- Error tracking integration (Sentry, Bugsnag) + +**Estimated Timeline:** 3 months + +--- + +## Version 1.3 (Q4 2026) - Enterprise Features + +**Focus:** Advanced features for large deployments + +### Multi-Database +- Read replicas support +- Connection pooling +- Query load balancing +- Cross-database transactions + +### Advanced Caching +- Distributed cache with Redis Cluster +- Cache warming strategies +- Intelligent cache invalidation +- Cache analytics dashboard + +### Observability +- Distributed tracing (OpenTelemetry) +- Metrics collection (Prometheus) +- Log aggregation (ELK stack) +- Performance profiling (Blackfire) + +**Estimated Timeline:** 3-4 months + +--- + +## Version 2.0 (Q1 2027) - Major Evolution + +**Focus:** Next-generation features + +### API Evolution +- GraphQL API with schema generation +- API versioning (v1, v2) +- Batch operations +- WebSocket support for real-time + +### MCP Expansion +- Schema exploration tools (ListTables, DescribeTable) +- Query templates system +- Visual query builder +- Data modification tools (with strict security) + +### AI Integration +- AI-powered code suggestions +- Intelligent search with semantic understanding +- Automated test generation +- Documentation generation from code + +### Modern Frontend +- Inertia.js support (optional) +- Vue/React component library +- Mobile app SDK (Flutter/React Native) +- Progressive Web App (PWA) kit + +**Estimated Timeline:** 4-6 months + +--- + +## Version 2.1+ (2027+) - Ecosystem Growth + +### Plugin Marketplace +- Plugin discovery and installation +- Revenue sharing for commercial plugins +- Plugin verification and security scanning +- Community ratings and reviews + +### SaaS Starter Kits +- Multi-tenant SaaS template +- Subscription billing integration +- Team management patterns +- Usage-based billing + +### Industry-Specific Modules +- E-commerce module +- CMS module +- CRM module +- Project management module +- Marketing automation + +### Cloud-Native +- Kubernetes deployment templates +- Serverless support (Laravel Vapor) +- Edge computing integration +- Multi-region deployment + +--- + +## Strategic Goals + +### Community Growth +- Reach 1,000 GitHub stars by EOY 2026 +- Build contributor community (20+ active contributors) +- Host monthly community calls +- Create Discord/Slack community + +### Documentation Excellence +- Interactive documentation with live examples +- Video course for framework mastery +- Architecture decision records (ADRs) +- Case studies from real deployments + +### Performance Targets +- < 50ms average response time +- Support 10,000+ req/sec on standard hardware +- 99.9% uptime SLA capability +- Optimize for low memory usage + +### Security Commitment +- Monthly security audits +- Bug bounty program +- Automatic dependency updates +- Security response team + +### Developer Satisfaction +- Package installation < 5 minutes +- First feature shipped < 1 hour +- Comprehensive error messages +- Excellent IDE support (PHPStorm, VS Code) + +--- + +## Contributing to the Roadmap + +This roadmap is community-driven! We welcome: + +- **Feature proposals** - Open GitHub discussions +- **Sponsorship** - Fund specific features +- **Code contributions** - Pick tasks from TODO files +- **Feedback** - Tell us what matters to you + +### How to Propose Features + +1. **Check existing proposals** - Search GitHub discussions +2. **Open a discussion** - Explain the problem and use case +3. **Gather feedback** - Community votes and discusses +4. **Create RFC** - Detailed technical proposal +5. **Implementation** - Build it or sponsor development + +### Sponsorship Opportunities + +Sponsor development of specific features: +- **Gold ($5,000+)** - Choose a major feature from v2.0+ +- **Silver ($2,000-$4,999)** - Choose a medium feature from v1.x +- **Bronze ($500-$1,999)** - Choose a small feature or bug fix + +Contact: support@host.uk.com + +--- + +## Package-Specific Roadmaps + +For detailed tasks, see package TODO files: +- [Core PHP →](/packages/core-php/TODO.md) +- [Admin →](/packages/core-admin/TODO.md) +- [API →](/packages/core-api/TODO.md) +- [MCP →](/packages/core-mcp/TODO.md) + +--- + +**Last Updated:** January 2026 +**License:** EUPL-1.2 +**Repository:** https://github.com/host-uk/core-php diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..26e576b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,182 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.x | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them via email to: **support@host.uk.com** + +You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message. + +## What to Include + +Please include the following information in your report: + +- **Type of vulnerability** (e.g., SQL injection, XSS, authentication bypass) +- **Full paths** of source file(s) related to the vulnerability +- **Location** of the affected source code (tag/branch/commit or direct URL) +- **Step-by-step instructions** to reproduce the issue +- **Proof-of-concept or exploit code** (if possible) +- **Impact** of the vulnerability and how an attacker might exploit it + +This information will help us triage your report more quickly. + +## Response Process + +1. **Acknowledgment** - We'll confirm receipt of your vulnerability report within 48 hours +2. **Assessment** - We'll assess the vulnerability and determine its severity (typically within 5 business days) +3. **Fix Development** - We'll develop a fix for the vulnerability +4. **Disclosure** - Once a fix is available, we'll: + - Release a security patch + - Publish a security advisory + - Credit the reporter (unless you prefer to remain anonymous) + +## Security Update Policy + +Security updates are released as soon as possible after a vulnerability is confirmed and patched. We follow these severity levels: + +### Critical +- **Response time:** Within 24 hours +- **Patch release:** Within 48 hours +- **Examples:** Remote code execution, SQL injection, authentication bypass + +### High +- **Response time:** Within 48 hours +- **Patch release:** Within 5 business days +- **Examples:** Privilege escalation, XSS, CSRF + +### Medium +- **Response time:** Within 5 business days +- **Patch release:** Next scheduled release +- **Examples:** Information disclosure, weak cryptography + +### Low +- **Response time:** Within 10 business days +- **Patch release:** Next scheduled release +- **Examples:** Minor security improvements + +## Security Features + +The Core PHP Framework includes several security features: + +### Multi-Tenant Isolation +- Automatic workspace scoping prevents cross-tenant data access +- Strict mode throws exceptions on missing workspace context +- Request validation ensures workspace context authenticity + +### API Security +- Bcrypt hashing for API keys (SHA-256 legacy support) +- Rate limiting per workspace with burst allowance +- HMAC-SHA256 webhook signing +- Scope-based permissions + +### SQL Injection Prevention +- Multi-layer query validation (MCP package) +- Blocked keywords (INSERT, UPDATE, DELETE, DROP) +- Pattern detection for SQL injection attempts +- Read-only database connection support +- Table access controls + +### Input Sanitization +- Built-in HTML/JS sanitization +- XSS prevention +- Email validation and disposable email blocking + +### Security Headers +- Content Security Policy (CSP) +- HSTS, X-Frame-Options, X-Content-Type-Options +- Referrer Policy +- Permissions Policy + +### Action Gate System +- Request whitelisting for sensitive operations +- Training mode for development +- Audit logging for all actions + +## Security Best Practices + +When using the Core PHP Framework: + +### API Keys +- Store API keys securely (never in version control) +- Use environment variables or secure key management +- Rotate keys regularly +- Use minimal required scopes + +### Database Access +- Use read-only connections for MCP tools +- Configure blocked tables for sensitive data +- Enable query whitelisting in production + +### Workspace Context +- Always validate workspace context in custom tools +- Use `RequiresWorkspaceContext` trait +- Never bypass workspace scoping + +### Rate Limiting +- Configure appropriate limits per tier +- Monitor rate limit violations +- Implement backoff strategies in API clients + +### Activity Logging +- Enable activity logging for sensitive operations +- Regularly review activity logs +- Set appropriate retention periods + +## Security Changelog + +See [packages/core-mcp/changelog/2026/jan/security.md](packages/core-mcp/changelog/2026/jan/security.md) for recent security fixes. + +## Credits + +We appreciate the security research community and would like to thank the following researchers for responsibly disclosing vulnerabilities: + +- *No vulnerabilities reported yet* + +## Bug Bounty Program + +We do not currently have a formal bug bounty program, but we deeply appreciate security research. Researchers who report valid security vulnerabilities will be: + +- Credited in our security advisories (if desired) +- Listed in this document +- Given early access to security patches + +## PGP Key + +For sensitive security reports, you may encrypt your message using our PGP key: + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- +[To be added if needed] +-----END PGP PUBLIC KEY BLOCK----- +``` + +## Contact + +- **Security Email:** support@host.uk.com +- **General Support:** https://github.com/host-uk/core-php/discussions +- **GitHub Security Advisories:** https://github.com/host-uk/core-php/security/advisories + +## Disclosure Policy + +When working with us according to this policy, you can expect us to: + +- Respond to your report promptly +- Keep you informed about our progress +- Treat your report confidentially +- Credit your discovery publicly (if desired) +- Work with you to fully understand and resolve the issue + +We request that you: + +- Give us reasonable time to fix the vulnerability before public disclosure +- Make a good faith effort to avoid privacy violations, data destruction, and service disruption +- Do not access or modify data that doesn't belong to you +- Do not perform attacks that could harm reliability or integrity of our services diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..81ffb47 --- /dev/null +++ b/TODO.md @@ -0,0 +1,15 @@ +# Core PHP Framework - TODO + +No pending tasks. + +--- + +## Package Changelogs + +For completed features and implementation details, see each package's changelog: + +- `changelog/` (this repo) +- [core-admin changelog](https://github.com/host-uk/core-admin) +- [core-api changelog](https://github.com/host-uk/core-api) +- [core-mcp changelog](https://github.com/host-uk/core-mcp) +- [core-tenant changelog](https://github.com/host-uk/core-tenant) diff --git a/changelog/2026/jan/CORE_PACKAGE_PLAN.md b/changelog/2026/jan/CORE_PACKAGE_PLAN.md new file mode 100644 index 0000000..1999451 --- /dev/null +++ b/changelog/2026/jan/CORE_PACKAGE_PLAN.md @@ -0,0 +1,308 @@ +# Core Package Release Plan + +**Package:** `host-uk/core` (GitHub: host-uk/core) +**Namespace:** `Core\` (not `Snide\` - that's *barf*) +**Usage:** ``, `Core\Front\Components\Button::make()` + +--- + +## Value Proposition + +Core provides: +1. **Thin Flux Wrappers** - `` components that pass through to `` with 100% parity +2. **HLCRF Layout System** - Compositor pattern for page layouts (Header, Left, Content, Right, Footer) +3. **FontAwesome Pro Integration** - Custom icon system with brand/jelly auto-detection +4. **PHP Builders** - Programmatic UI composition (`Button::make()->primary()`) +5. **Graceful Degradation** - Falls back to free versions of Flux/FontAwesome + +--- + +## Detection Strategy + +### Flux Pro vs Free + +```php +use Composer\InstalledVersions; + +class Core +{ + public static function hasFluxPro(): bool + { + return InstalledVersions::isInstalled('livewire/flux-pro'); + } + + public static function proComponents(): array + { + return [ + 'calendar', 'date-picker', 'time-picker', + 'editor', 'composer', + 'chart', 'kanban', + 'command', 'context', + 'autocomplete', 'pillbox', 'slider', + 'file-upload', + ]; + } +} +``` + +### FontAwesome Pro vs Free + +```php +class Core +{ + public static function hasFontAwesomePro(): bool + { + // Check for FA Pro kit or CDN link in config + return config('core.fontawesome.pro', false); + } + + public static function faStyles(): array + { + // Pro: solid, regular, light, thin, duotone, brands, sharp, jelly + // Free: solid, regular, brands + return self::hasFontAwesomePro() + ? ['solid', 'regular', 'light', 'thin', 'duotone', 'brands', 'sharp', 'jelly'] + : ['solid', 'regular', 'brands']; + } +} +``` + +--- + +## Graceful Degradation + +### Pro-Only Flux Components + +When Flux Pro isn't installed, `` etc. should: + +**Option A: Helpful Error** (recommended for development) +```blade +{{-- calendar.blade.php --}} +@if(Core::hasFluxPro()) + +@else +
+ Calendar requires Flux Pro. + Learn more +
+@endif +``` + +**Option B: Silent Fallback** (for production) +```blade +{{-- calendar.blade.php --}} +@if(Core::hasFluxPro()) + +@else + {{-- Graceful degradation: render nothing or a basic HTML input --}} + +@endif +``` + +### FontAwesome Style Fallback + +```php +// In icon.blade.php +$availableStyles = Core::faStyles(); + +// Map pro-only styles to free equivalents +$styleFallback = [ + 'light' => 'regular', // FA Light → FA Regular + 'thin' => 'regular', // FA Thin → FA Regular + 'duotone' => 'solid', // FA Duotone → FA Solid + 'sharp' => 'solid', // FA Sharp → FA Solid + 'jelly' => 'solid', // Host UK Jelly → FA Solid +]; + +if (!in_array($iconStyle, $availableStyles)) { + $iconStyle = $styleFallback[$iconStyle] ?? 'fa-solid'; +} +``` + +--- + +## Package Structure (Root Level) + +``` +host-uk/core/ +├── composer.json +├── LICENSE +├── README.md +├── Core/ +│ ├── Boot.php # ServiceProvider +│ ├── Core.php # Detection helpers + facade +│ ├── Front/ +│ │ ├── Boot.php +│ │ └── Components/ +│ │ ├── CoreTagCompiler.php # syntax +│ │ ├── View/ +│ │ │ └── Blade/ # 100+ components +│ │ │ ├── button.blade.php +│ │ │ ├── icon.blade.php +│ │ │ ├── layout.blade.php +│ │ │ └── layout/ +│ │ ├── Button.php # PHP Builder +│ │ ├── Card.php +│ │ ├── Heading.php +│ │ ├── Layout.php # HLCRF compositor +│ │ ├── NavList.php +│ │ └── Text.php +│ └── config.php # Package config +├── tests/ +│ └── Feature/ +│ └── CoreComponentsTest.php +└── .github/ + └── workflows/ + └── tests.yml +``` + +**Note:** This mirrors Host Hub's current `app/Core/` structure exactly, just at root level. Minimal refactoring needed. + +--- + +## composer.json + +```json +{ + "name": "host-uk/core", + "description": "Core UI component library for Laravel - Flux Pro/Free compatible", + "keywords": ["laravel", "livewire", "flux", "components", "ui"], + "license": "MIT", + "authors": [ + { + "name": "Snider", + "homepage": "https://host.uk.com" + } + ], + "require": { + "php": "^8.2", + "laravel/framework": "^11.0|^12.0", + "livewire/livewire": "^3.0", + "livewire/flux": "^2.0" + }, + "suggest": { + "livewire/flux-pro": "Required for Pro components (calendar, editor, chart, etc.)" + }, + "autoload": { + "psr-4": { + "Core\\": "src/Core/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Core\\CoreServiceProvider" + ] + } + } +} +``` + +--- + +## Configuration + +```php +// config/core.php +return [ + /* + |-------------------------------------------------------------------------- + | FontAwesome Configuration + |-------------------------------------------------------------------------- + */ + 'fontawesome' => [ + 'pro' => env('FONTAWESOME_PRO', false), + 'kit' => env('FONTAWESOME_KIT'), // e.g., 'abc123def456' + ], + + /* + |-------------------------------------------------------------------------- + | Fallback Behaviour + |-------------------------------------------------------------------------- + | How to handle Pro components when Pro isn't installed. + | Options: 'error', 'fallback', 'silent' + */ + 'pro_fallback' => env('CORE_PRO_FALLBACK', 'error'), +]; +``` + +--- + +## Migration Path + +### Step 1: Extract Core (Host Hub) +Move `app/Core/Front/Components/` to standalone package, update namespace `Core\` → `Core\` + +### Step 2: Install Package Back +```bash +composer require host-uk/core +``` + +### Step 3: Host Hub Uses Package +Replace `app/Core/Front/Components/` with import from package. Keep Host-specific stuff in `app/Core/`. + +--- + +## What Stays in Host Hub + +These are too app-specific for the package: +- `Core/Cdn/` - BunnyCDN integration +- `Core/Config/` - Multi-tenant config system +- `Core/Mail/` - EmailShield +- `Core/Seo/` - Schema, OG images +- `Core/Headers/` - Security headers (maybe extract later) +- `Core/Media/` - ImageOptimizer (maybe extract later) + +--- + +## What Goes in Package + +Universal value: +- `Core/Front/Components/` - All 100+ Blade components +- `Core/Front/Components/*.php` - PHP Builders +- `CoreTagCompiler.php` - `` syntax + +--- + +## Questions to Resolve + +1. **Package name:** `host-uk/core`? +2. **FontAwesome:** Detect Kit from asset URL, or require config? +3. **Fallback mode:** Default to 'error' (dev-friendly) or 'fallback' (prod-safe)? +4. **Jelly icons:** Include your custom FA style in package, or keep Host UK specific? + +--- + +## Implementation Progress + +### Done ✅ + +1. **Detection helpers** - `app/Core/Core.php` + - `Core::hasFluxPro()` - Uses Composer InstalledVersions + - `Core::hasFontAwesomePro()` - Uses config + - `Core::requiresFluxPro($component)` - Checks if component needs Pro + - `Core::fontAwesomeStyles()` - Returns available styles + - `Core::fontAwesomeFallback($style)` - Maps Pro→Free styles + +2. **Config file** - `app/Core/config.php` + - `fontawesome.pro` - Enable FA Pro styles + - `fontawesome.kit` - FA Kit ID + - `pro_fallback` - How to handle Pro components (error/fallback/silent) + +3. **Icon fallback** - `app/Core/Front/Components/View/Blade/icon.blade.php` + - Auto-detects FA Pro availability + - Falls back: jelly→solid, light→regular, thin→regular, duotone→solid + +4. **Test coverage** - 49 tests, 79 assertions + - Detection helper tests + - Icon fallback tests (Pro/Free scenarios) + - Full Flux parity tests + +### TODO + +1. Create pro-component wrappers with fallback (calendar, editor, chart, etc.) +2. Test with Flux Free only (remove flux-pro temporarily) +3. Extract to separate repo +4. Update namespace `Core\` → `Core\` +5. Create composer.json for package +6. Publish to Packagist diff --git a/changelog/2026/jan/TASK-event-driven-module-loading.md b/changelog/2026/jan/TASK-event-driven-module-loading.md new file mode 100644 index 0000000..1b958d8 --- /dev/null +++ b/changelog/2026/jan/TASK-event-driven-module-loading.md @@ -0,0 +1,809 @@ +# TASK: Event-Driven Module Loading + +**Status:** complete +**Created:** 2026-01-15 +**Last Updated:** 2026-01-15 by Claude (Phase 5 complete) +**Complexity:** medium (5 phases) +**Estimated Phases:** 5 +**Completed Phases:** 5/5 + +--- + +## Objective + +Replace the static provider list in `Core\Boot` with an event-driven module loading system. Modules declare interest in lifecycle events via static `$listens` arrays in their `Boot.php` files. The framework fires events; modules self-register only when relevant. Result: most modules never load for most requests. + +--- + +## Background + +### Current State + +`Core\Boot::$providers` hardcodes all providers: + +```php +public static array $providers = [ + \Core\Bouncer\Boot::class, + \Core\Config\Boot::class, + // ... 30+ more + \Mod\Commerce\Boot::class, + \Mod\Social\Boot::class, +]; +``` + +Every request loads every module. A webhook request loads the entire admin UI. A public page loads payment processing. + +### Target State + +```php +// Mod/Commerce/Boot.php +class Boot +{ + public static array $listens = [ + PaymentRequested::class => 'bootPayments', + AdminPanelBooting::class => 'registerAdmin', + ApiRoutesRegistering::class => 'registerApi', + ]; + + public function bootPayments(): void { /* load payment stuff */ } + public function registerAdmin(): void { /* load admin routes/views */ } +} +``` + +Framework scans `$listens` without instantiation. Wires lazy listeners. Events fire naturally during request. Only relevant modules boot. + +### Design Principles + +1. **Framework announces, modules decide** — Core fires events, doesn't call modules directly +2. **Static declaration, lazy instantiation** — Read `$listens` without creating objects +3. **Infrastructure vs features** — Some Core modules always load (Bouncer), others lazy +4. **Convention over configuration** — Scan `Mod/*/Boot.php`, no manifest file + +--- + +## Scope + +- **Files modified:** ~15 +- **Files created:** ~8 +- **Events defined:** ~10-15 lifecycle events +- **Tests:** 40-60 target + +--- + +## Module Classification + +### Always-On Infrastructure (loaded via traditional providers) + +| Module | Reason | +|--------|--------| +| `Core\Bouncer` | Security — must run first, blocks bad requests | +| `Core\Input` | WAF — runs pre-Laravel in `Init::handle()` | +| `Core\Front` | Frontage routing — fires the events others listen to | +| `Core\Headers` | Security headers — every response needs them | +| `Core\Config` | Config system — everything depends on it | + +### Lazy Core (event-driven) + +| Module | Loads When | +|--------|------------| +| `Core\Cdn` | Media upload/serve events | +| `Core\Media` | Media processing events | +| `Core\Seo` | Public page rendering | +| `Core\Search` | Search queries | +| `Core\Mail` | Email sending events | +| `Core\Helpers` | May stay always-on (utility) | +| `Core\Storage` | Storage operations | + +### Lazy Mod (event-driven) + +All modules in `Mod/*` become event-driven. + +--- + +## Phase Overview + +| Phase | Name | Status | ACs | Dependencies | +|-------|------|--------|-----|--------------| +| 1 | Event Definitions | ✅ Complete | AC1-5 | None | +| 2 | Module Scanner | ✅ Complete | AC6-10 | Phase 1 | +| 3 | Core Migration | ⏳ Skipped | AC11-15 | Phase 2 | +| 4 | Mod Migration | ✅ Complete | AC16-22 | Phase 2 | +| 5 | Verification & Cleanup | ✅ Complete | AC23-27 | Phases 3, 4 | + +--- + +## Acceptance Criteria + +### Phase 1: Event Definitions + +- [x] AC1: `Core\Events\` namespace exists with lifecycle event classes +- [x] AC2: Events defined for: `FrameworkBooted`, `AdminPanelBooting`, `ApiRoutesRegistering`, `WebRoutesRegistering`, `McpToolsRegistering`, `QueueWorkerBooting`, `ConsoleBooting`, `MediaRequested`, `SearchRequested`, `MailSending` +- [x] AC3: Each event class is a simple value object (no logic) +- [x] AC4: Events documented with PHPDoc describing when they fire +- [ ] AC5: Test verifies all event classes are instantiable + +### Phase 2: Module Scanner + +- [x] AC6: `Core\ModuleScanner` class exists +- [x] AC7: Scanner reads `Boot.php` files from configured paths without instantiation +- [x] AC8: Scanner extracts `public static array $listens` via reflection (not file parsing) +- [x] AC9: Scanner returns array of `[event => [module => method]]` mappings +- [ ] AC10: Test verifies scanner correctly reads a mock Boot class with `$listens` + +### Phase 3: Core Module Migration + +- [ ] AC11: `Core\Boot::$providers` split into `$infrastructure` (always-on) and removed lazy modules +- [ ] AC12: `Core\Cdn\Boot` converted to `$listens` pattern +- [ ] AC13: `Core\Media\Boot` converted to `$listens` pattern +- [ ] AC14: `Core\Seo\Boot` converted to `$listens` pattern +- [ ] AC15: Tests verify lazy Core modules only instantiate when their events fire + +### Phase 4: Mod Module Migration + +- [x] AC16: All 16 modules converted to `$listens` pattern: + - `Mod\Agentic`, `Mod\Analytics`, `Mod\Api`, `Mod\Web`, `Mod\Commerce`, `Mod\Content` + - `Mod\Developer`, `Mod\Hub`, `Mod\Mcp`, `Mod\Notify`, `Mod\Social`, `Mod\Support` + - `Mod\Tenant`, `Mod\Tools`, `Mod\Trees`, `Mod\Trust` +- [x] AC17: Each module's `Boot.php` has `$listens` array declaring relevant events +- [x] AC18: Each module's routes register via `WebRoutesRegistering`, `ApiRoutesRegistering`, or `AdminPanelBooting` as appropriate +- [x] AC19: Each module's views/components register via appropriate events +- [x] AC20: Modules with commands register via `ConsoleBooting` +- [ ] AC21: Modules with queue jobs register via `QueueWorkerBooting` +- [x] AC21.5: Modules with MCP tools register via `McpToolsRegistering` using handler classes +- [ ] AC22: Tests verify at least 3 modules only load when their events fire + +### Phase 5: Verification & Cleanup + +- [x] AC23: `Core\Boot::$providers` contains only infrastructure modules +- [x] AC24: No `Mod\*` classes appear in `Core\Boot` (modules load via events) +- [x] AC25: Unit test suite passes (503+ tests in ~5s), Feature tests require DB +- [ ] AC26: Benchmark shows reduced memory/bootstrap time for API-only request +- [x] AC27: Documentation updated in `doc/rfc/EVENT-DRIVEN-MODULES.md` + +--- + +## Implementation Checklist + +### Phase 1: Event Definitions + +- [x] File: `app/Core/Events/FrameworkBooted.php` +- [x] File: `app/Core/Events/AdminPanelBooting.php` +- [x] File: `app/Core/Events/ApiRoutesRegistering.php` +- [x] File: `app/Core/Events/WebRoutesRegistering.php` +- [x] File: `app/Core/Events/McpToolsRegistering.php` +- [x] File: `app/Core/Events/QueueWorkerBooting.php` +- [x] File: `app/Core/Events/ConsoleBooting.php` +- [x] File: `app/Core/Events/MediaRequested.php` +- [x] File: `app/Core/Events/SearchRequested.php` +- [x] File: `app/Core/Events/MailSending.php` +- [x] File: `app/Core/Front/Mcp/Contracts/McpToolHandler.php` +- [x] File: `app/Core/Front/Mcp/McpContext.php` +- [ ] Test: `app/Core/Tests/Unit/Events/LifecycleEventsTest.php` + +### Phase 2: Module Scanner + +- [x] File: `app/Core/ModuleScanner.php` +- [x] File: `app/Core/ModuleRegistry.php` (stores scanned mappings) +- [x] File: `app/Core/LazyModuleListener.php` (wraps module method as listener) +- [x] File: `app/Core/LifecycleEventProvider.php` (fires events, processes requests) +- [x] Update: `app/Core/Boot.php` — added LifecycleEventProvider +- [x] Update: `app/Core/Front/Web/Boot.php` — fires WebRoutesRegistering +- [x] Update: `app/Core/Front/Admin/Boot.php` — fires AdminPanelBooting +- [x] Update: `app/Core/Front/Api/Boot.php` — fires ApiRoutesRegistering +- [x] Test: `app/Core/Tests/Unit/ModuleScannerTest.php` +- [x] Test: `app/Core/Tests/Unit/LazyModuleListenerTest.php` +- [x] Test: `app/Core/Tests/Feature/ModuleScannerIntegrationTest.php` + +### Phase 3: Core Module Migration + +- [ ] Update: `app/Core/Boot.php` — split `$providers` +- [ ] Update: `app/Core/Cdn/Boot.php` — add `$listens`, remove ServiceProvider pattern +- [ ] Update: `app/Core/Media/Boot.php` — add `$listens` +- [ ] Update: `app/Core/Seo/Boot.php` — add `$listens` +- [ ] Update: `app/Core/Search/Boot.php` — add `$listens` +- [ ] Update: `app/Core/Mail/Boot.php` — add `$listens` +- [ ] Test: `app/Core/Tests/Feature/LazyCoreModulesTest.php` + +### Phase 4: Mod Module Migration + +All 16 Mod modules converted to `$listens` pattern: + +- [x] Update: `app/Mod/Agentic/Boot.php` ✓ (AdminPanelBooting, ConsoleBooting, McpToolsRegistering) +- [x] Update: `app/Mod/Analytics/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering, ApiRoutesRegistering, ConsoleBooting) +- [x] Update: `app/Mod/Api/Boot.php` ✓ (ApiRoutesRegistering, ConsoleBooting) +- [x] Update: `app/Mod/Bio/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering, ApiRoutesRegistering, ConsoleBooting) +- [x] Update: `app/Mod/Commerce/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering, ConsoleBooting) +- [x] Update: `app/Mod/Content/Boot.php` ✓ (WebRoutesRegistering, ApiRoutesRegistering, ConsoleBooting, McpToolsRegistering) +- [x] Update: `app/Mod/Developer/Boot.php` ✓ (AdminPanelBooting) +- [x] Update: `app/Mod/Hub/Boot.php` ✓ (AdminPanelBooting) +- [x] Update: `app/Mod/Mcp/Boot.php` ✓ (AdminPanelBooting, ConsoleBooting, McpToolsRegistering) +- [x] Update: `app/Mod/Notify/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering) +- [x] Update: `app/Mod/Social/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering, ApiRoutesRegistering, ConsoleBooting) +- [x] Update: `app/Mod/Support/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering) +- [x] Update: `app/Mod/Tenant/Boot.php` ✓ (WebRoutesRegistering, ConsoleBooting) +- [x] Update: `app/Mod/Tools/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering) +- [x] Update: `app/Mod/Trees/Boot.php` ✓ (WebRoutesRegistering, ConsoleBooting) +- [x] Update: `app/Mod/Trust/Boot.php` ✓ (AdminPanelBooting, WebRoutesRegistering, ApiRoutesRegistering) +- [x] Legacy patterns removed (no registerRoutes, registerViews, registerCommands methods) +- [ ] Test: `app/Mod/Tests/Feature/LazyModLoadingTest.php` + +### Phase 5: Verification & Cleanup + +- [x] Create: `doc/rfc/EVENT-DRIVEN-MODULES.md` — architecture reference (comprehensive) +- [x] Create: `app/Core/Tests/Unit/ModuleScannerTest.php` — unit tests for scanner +- [x] Create: `app/Core/Tests/Unit/LazyModuleListenerTest.php` — unit tests for lazy listener +- [x] Create: `app/Core/Tests/Feature/ModuleScannerIntegrationTest.php` — integration tests +- [x] Run: Unit test suite (75 Core tests pass, 503+ total Unit tests) + +--- + +## Technical Design + +### Security Model + +Lazy loading isn't just optimisation — it's a security boundary. + +**Defence in depth:** + +1. **Bouncer** — blocks bad requests before anything loads +2. **Lazy loading** — modules only exist when relevant events fire +3. **Capability requests** — modules request resources, Core grants/denies +4. **Validation** — Core sanitises everything modules ask for + +A misbehaving module can't: +- Register routes it wasn't asked about (Core controls route registration) +- Add nav items to sections it doesn't own (Core validates structure) +- Access services it didn't declare (not loaded, not in memory) +- Corrupt other modules' state (they don't exist yet) + +### Event as Capability Request + +Events are **request forms**, not direct access to infrastructure. Modules declare what they want; Core decides what to grant. + +```php +// BAD: Module directly modifies infrastructure (Option A from discussion) +public function registerAdmin(AdminPanelBooting $event): void +{ + $event->navigation->add('commerce', ...); // Direct mutation — dangerous +} + +// GOOD: Module requests, Core processes (Option C) +public function registerAdmin(AdminPanelBooting $event): void +{ + $event->navigation([ // Request form — safe + 'key' => 'commerce', + 'label' => 'Commerce', + 'icon' => 'credit-card', + 'route' => 'admin.commerce.index', + ]); + + $event->routes(function () { + // Route definitions — Core will register them + }); + + $event->views('commerce', __DIR__.'/View/Blade'); +} +``` + +Core collects all requests, then processes them: + +```php +// In Core, after event fires: +$event = new AdminPanelBooting(); +event($event); + +// Core processes requests with full control +foreach ($event->navigationRequests() as $request) { + if ($this->validateNavRequest($request)) { + $this->navigation->add($request); + } +} + +foreach ($event->routeRequests() as $callback) { + Route::middleware('admin')->group($callback); +} + +foreach ($event->viewRequests() as [$namespace, $path]) { + if ($this->validateViewPath($path)) { + view()->addNamespace($namespace, $path); + } +} +``` + +### ModuleScanner Implementation + +```php +namespace Core; + +class ModuleScanner +{ + public function scan(array $paths): array + { + $mappings = []; + + foreach ($paths as $path) { + foreach (glob("{$path}/*/Boot.php") as $file) { + $class = $this->classFromFile($file); + + if (!class_exists($class)) { + continue; + } + + $reflection = new \ReflectionClass($class); + + if (!$reflection->hasProperty('listens')) { + continue; + } + + $prop = $reflection->getProperty('listens'); + if (!$prop->isStatic() || !$prop->isPublic()) { + continue; + } + + $listens = $prop->getValue(); + + foreach ($listens as $event => $method) { + $mappings[$event][$class] = $method; + } + } + } + + return $mappings; + } + + private function classFromFile(string $file): string + { + // Extract namespace\class from file path + // e.g., app/Mod/Commerce/Boot.php → Mod\Commerce\Boot + } +} +``` + +### Base Event Class + +All lifecycle events extend a base that provides the request collection API: + +```php +namespace Core\Events; + +abstract class LifecycleEvent +{ + protected array $navigationRequests = []; + protected array $routeRequests = []; + protected array $viewRequests = []; + protected array $middlewareRequests = []; + + public function navigation(array $item): void + { + $this->navigationRequests[] = $item; + } + + public function routes(callable $callback): void + { + $this->routeRequests[] = $callback; + } + + public function views(string $namespace, string $path): void + { + $this->viewRequests[] = [$namespace, $path]; + } + + public function middleware(string $alias, string $class): void + { + $this->middlewareRequests[] = [$alias, $class]; + } + + // Getters for Core to process + public function navigationRequests(): array { return $this->navigationRequests; } + public function routeRequests(): array { return $this->routeRequests; } + public function viewRequests(): array { return $this->viewRequests; } + public function middlewareRequests(): array { return $this->middlewareRequests; } +} +``` + +### LazyModuleListener Implementation + +```php +namespace Core; + +class LazyModuleListener +{ + public function __construct( + private string $moduleClass, + private string $method + ) {} + + public function handle(object $event): void + { + // Module only instantiated NOW, when event fires + $module = app()->make($this->moduleClass); + $module->{$this->method}($event); + } +} +``` + +### Boot.php Integration Point + +```php +// In Boot::app(), after withProviders(): +->withEvents(function () { + $scanner = new ModuleScanner(); + $mappings = $scanner->scan([ + app_path('Core'), + app_path('Mod'), + ]); + + foreach ($mappings as $event => $listeners) { + foreach ($listeners as $class => $method) { + Event::listen($event, new LazyModuleListener($class, $method)); + } + } +}) +``` + +### Example Converted Module + +```php +// app/Mod/Commerce/Boot.php +namespace Mod\Commerce; + +use Core\Events\AdminPanelBooting; +use Core\Events\ApiRoutesRegistering; +use Core\Events\WebRoutesRegistering; +use Core\Events\QueueWorkerBooting; + +class Boot +{ + public static array $listens = [ + AdminPanelBooting::class => 'registerAdmin', + ApiRoutesRegistering::class => 'registerApiRoutes', + WebRoutesRegistering::class => 'registerWebRoutes', + QueueWorkerBooting::class => 'registerJobs', + ]; + + public function registerAdmin(AdminPanelBooting $event): void + { + // Request navigation — Core will validate and add + $event->navigation([ + 'key' => 'commerce', + 'label' => 'Commerce', + 'icon' => 'credit-card', + 'route' => 'admin.commerce.index', + 'children' => [ + ['key' => 'products', 'label' => 'Products', 'route' => 'admin.commerce.products'], + ['key' => 'orders', 'label' => 'Orders', 'route' => 'admin.commerce.orders'], + ['key' => 'subscriptions', 'label' => 'Subscriptions', 'route' => 'admin.commerce.subscriptions'], + ], + ]); + + // Request routes — Core will wrap with middleware + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + + // Request view namespace — Core will validate path + $event->views('commerce', __DIR__.'/View/Blade'); + } + + public function registerApiRoutes(ApiRoutesRegistering $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/api.php'); + } + + public function registerWebRoutes(WebRoutesRegistering $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + + public function registerJobs(QueueWorkerBooting $event): void + { + // Request job registration if needed + } +} +``` + +### MCP Tool Registration + +MCP tools use handler classes instead of closures for better testability and separation. + +**McpToolHandler interface:** + +```php +namespace Core\Front\Mcp\Contracts; + +interface McpToolHandler +{ + /** + * JSON schema describing the tool for Claude. + */ + public static function schema(): array; + + /** + * Handle tool invocation. + */ + public function handle(array $args, McpContext $context): array; +} +``` + +**McpContext abstracts transport (stdio vs HTTP):** + +```php +namespace Core\Front\Mcp; + +class McpContext +{ + public function __construct( + private ?string $sessionId = null, + private ?AgentPlan $currentPlan = null, + private ?Closure $notificationCallback = null, + ) {} + + public function logToSession(string $message): void { /* ... */ } + public function sendNotification(string $method, array $params): void { /* ... */ } + public function getSessionId(): ?string { return $this->sessionId; } + public function getCurrentPlan(): ?AgentPlan { return $this->currentPlan; } +} +``` + +**McpToolsRegistering event:** + +```php +namespace Core\Events; + +class McpToolsRegistering extends LifecycleEvent +{ + protected array $handlers = []; + + public function handler(string $handlerClass): void + { + if (!is_a($handlerClass, McpToolHandler::class, true)) { + throw new \InvalidArgumentException("{$handlerClass} must implement McpToolHandler"); + } + $this->handlers[] = $handlerClass; + } + + public function handlers(): array + { + return $this->handlers; + } +} +``` + +**Example tool handler:** + +```php +// Mod/Content/Mcp/ContentStatusHandler.php +namespace Mod\Content\Mcp; + +use Core\Front\Mcp\Contracts\McpToolHandler; +use Core\Front\Mcp\McpContext; + +class ContentStatusHandler implements McpToolHandler +{ + public static function schema(): array + { + return [ + 'name' => 'content_status', + 'description' => 'Get content generation pipeline status', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + ], + ]; + } + + public function handle(array $args, McpContext $context): array + { + $context->logToSession('Checking content pipeline status...'); + + // ... implementation + + return ['status' => 'ok', 'providers' => [...]]; + } +} +``` + +**Module registration:** + +```php +// Mod/Content/Boot.php +public static array $listens = [ + McpToolsRegistering::class => 'registerMcpTools', +]; + +public function registerMcpTools(McpToolsRegistering $event): void +{ + $event->handler(\Mod\Content\Mcp\ContentStatusHandler::class); + $event->handler(\Mod\Content\Mcp\ContentBriefCreateHandler::class); + $event->handler(\Mod\Content\Mcp\ContentBriefListHandler::class); + // ... etc +} +``` + +**Frontage integration (Stdio):** + +The McpAgentServerCommand becomes a thin shell that: +1. Fires `McpToolsRegistering` event at startup +2. Collects all handler classes +3. Builds tool list from `::schema()` methods +4. Routes tool calls to handler instances with `McpContext` + +```php +// In McpAgentServerCommand::handle() +$event = new McpToolsRegistering(); +event($event); + +$context = new McpContext( + sessionId: $this->sessionId, + currentPlan: $this->currentPlan, + notificationCallback: fn($m, $p) => $this->sendNotification($m, $p), +); + +foreach ($event->handlers() as $handlerClass) { + $schema = $handlerClass::schema(); + $this->tools[$schema['name']] = [ + 'schema' => $schema, + 'handler' => fn($args) => app($handlerClass)->handle($args, $context), + ]; +} +``` + +--- + +## Sync Protocol + +### Keeping This Document Current + +This document may drift from implementation as code changes. To re-sync: + +1. **After implementation changes:** + ```bash + # Agent prompt: + "Review tasks/TASK-event-driven-module-loading.md against current implementation. + Update acceptance criteria status, note any deviations in Notes section." + ``` + +2. **Before resuming work:** + ```bash + # Agent prompt: + "Read tasks/TASK-event-driven-module-loading.md. + Check which phases are complete by examining the actual files. + Update Phase Overview table with current status." + ``` + +3. **Automated sync points:** + - [ ] After each phase completion, update Phase Overview + - [ ] After test runs, update test counts in Phase Completion Log + - [ ] After any design changes, update Technical Design section + +### Code Locations to Check + +When syncing, verify these key files: + +| Check | File | What to Verify | +|-------|------|----------------| +| Events exist | `app/Core/Events/*.php` | All AC2 events defined | +| Scanner works | `app/Core/ModuleScanner.php` | Class exists, has `scan()` | +| Boot updated | `app/Core/Boot.php` | Uses scanner, has `$infrastructure` | +| Mods converted | `app/Mod/*/Boot.php` | Has `$listens` array | + +### Deviation Log + +Record any implementation decisions that differ from this plan: + +| Date | Section | Change | Reason | +|------|---------|--------|--------| +| - | - | - | - | + +--- + +## Verification Results + +*To be filled by verification agent after implementation* + +--- + +## Phase Completion Log + +### Phase 1: Event Definitions (2026-01-15) + +Created all lifecycle event classes: +- `Core/Events/LifecycleEvent.php` - Base class with request collection API +- `Core/Events/FrameworkBooted.php` +- `Core/Events/AdminPanelBooting.php` +- `Core/Events/ApiRoutesRegistering.php` +- `Core/Events/WebRoutesRegistering.php` +- `Core/Events/McpToolsRegistering.php` - With handler registration for MCP tools +- `Core/Events/QueueWorkerBooting.php` +- `Core/Events/ConsoleBooting.php` +- `Core/Events/MediaRequested.php` +- `Core/Events/SearchRequested.php` +- `Core/Events/MailSending.php` + +Also created MCP infrastructure: +- `Core/Front/Mcp/Contracts/McpToolHandler.php` - Interface for MCP tool handlers +- `Core/Front/Mcp/McpContext.php` - Context object for transport abstraction + +### Phase 2: Module Scanner (2026-01-15) + +Created scanning and lazy loading infrastructure: +- `Core/ModuleScanner.php` - Scans Boot.php files for `$listens` via reflection +- `Core/LazyModuleListener.php` - Wraps module methods as event listeners +- `Core/ModuleRegistry.php` - Manages lazy module registration +- `Core/LifecycleEventProvider.php` - Wires everything together + +Integrated into application: +- Added `LifecycleEventProvider` to `Core/Boot::$providers` +- Updated `Core/Front/Web/Boot` to fire `WebRoutesRegistering` +- Updated `Core/Front/Admin/Boot` to fire `AdminPanelBooting` +- Updated `Core/Front/Api/Boot` to fire `ApiRoutesRegistering` + +Proof of concept modules converted: +- `Mod/Content/Boot.php` - listens to WebRoutesRegistering, ApiRoutesRegistering, ConsoleBooting, McpToolsRegistering +- `Mod/Agentic/Boot.php` - listens to AdminPanelBooting, ConsoleBooting, McpToolsRegistering + +### Phase 4: Mod Module Migration (2026-01-15) + +All 16 Mod modules converted to event-driven `$listens` pattern: + +**Modules converted:** +- Agentic, Analytics, Api, Bio, Commerce, Content, Developer, Hub, Mcp, Notify, Social, Support, Tenant, Tools, Trees, Trust + +**Legacy patterns removed:** +- No modules use `registerRoutes()`, `registerViews()`, `registerCommands()`, or `registerLivewireComponents()` +- All route/view/component registration moved to event handlers + +**CLI Frontage created:** +- `Core/Front/Cli/Boot.php` - fires ConsoleBooting event and processes: + - Artisan commands + - Translations + - Middleware aliases + - Policies + - Blade component paths + +### Phase 5: Verification & Cleanup (2026-01-15) + +**Tests created:** +- `Core/Tests/Unit/ModuleScannerTest.php` - Unit tests for `extractListens()` reflection +- `Core/Tests/Unit/LazyModuleListenerTest.php` - Unit tests for lazy module instantiation +- `Core/Tests/Feature/ModuleScannerIntegrationTest.php` - Integration tests with real modules + +**Documentation created:** +- `doc/rfc/EVENT-DRIVEN-MODULES.md` - Comprehensive RFC documenting: + - Architecture overview with diagrams + - Core components (ModuleScanner, ModuleRegistry, LazyModuleListener) + - Available lifecycle events + - Module implementation guide + - Migration guide from legacy pattern + - Testing examples + - Performance considerations + +**Test results:** +- Unit tests: 75 Core tests pass in 1.44s +- Total Unit tests: 503+ tests pass in ~5s +- Feature tests require database (not run in quick verification) + +--- + +## Notes + +### Open Questions + +1. **Event payload:** Should events carry context (e.g., `AdminPanelBooting` carries the navigation builder), or should modules pull from container? + +2. **Load order:** If Module A needs Module B's routes registered first, how do we handle? Priority property on `$listens`? + +3. **Proprietary modules:** Bio, Analytics, Social, Trust, Notify, Front — these won't be in the open-source release. How do they integrate? Same pattern, just not shipped? + +4. **Plug integration:** Does `Plug\Boot` become event-driven too, or stay always-on since it's a pure library? + +### Decisions Made + +- Infrastructure modules stay as traditional ServiceProviders (simpler, no benefit to lazy loading security/config) +- Modules don't extend ServiceProvider anymore — they're plain classes with `$listens` +- Scanner uses reflection, not file parsing (more reliable, handles inheritance) + +### References + +- Current `Core\Boot`: `app/Core/Boot.php:17-61` +- Current `Init`: `app/Core/Init.php` +- Module README: `app/Core/README.md` diff --git a/changelog/2026/jan/code-review.md b/changelog/2026/jan/code-review.md new file mode 100644 index 0000000..fef2814 --- /dev/null +++ b/changelog/2026/jan/code-review.md @@ -0,0 +1,181 @@ +# Core-PHP Code Review - January 2026 + +Comprehensive Opus-level code review of all Core/* modules. + +## Summary + +| Severity | Count | Status | +|----------|-------|--------| +| Critical | 15 | All Fixed | +| High | 52 | 51 Fixed | +| Medium | 38 | All Fixed | +| Low | 32 | All Fixed | + +--- + +## Critical Issues Fixed + +### Bouncer/BlocklistService.php +- **Missing table existence check** - Added cached `tableExists()` check. + +### Cdn/Services/StorageUrlResolver.php +- **Weak token hashing** - Changed to HMAC-SHA256. + +### Config/ConfigService.php +- **SQL injection via LIKE wildcards** - Added wildcard escaping. + +### Console/Boot.php +- **References non-existent commands** - Commented out missing commands. + +### Console/Commands/InstallCommand.php +- **Regex injection** - Added `preg_quote()`. + +### Input/Sanitiser.php +- **Nested arrays become null** - Implemented recursive filtering. + +### Mail/EmailShieldStat.php +- **Race condition** - Changed to atomic `insertOrIgnore()` + `increment()`. + +### ModuleScanner.php +- **Duplicate code** - Removed duplicate. +- **Missing namespaces** - Added Website and Plug namespace handling. + +### Search/Unified.php +- **Missing class_exists check** - Added guard. + +### Seo/Schema.php, SchemaBuilderService.php, SeoMetadata.php +- **XSS vulnerability** - Added `JSON_HEX_TAG` flag. + +### Storage/CacheResilienceProvider.php +- **Hardcoded phpredis** - Added Predis support with fallback. + +--- + +## High Severity Issues Fixed + +### Bouncer (3/3) +- BlocklistService auto-block workflow with pending/approved/rejected status +- TeapotController rate limiting with configurable max attempts +- HoneypotHit configurable severity levels + +### Cdn (4/5) +- BunnyStorageService retry logic with exponential backoff +- BunnyStorageService file size validation +- BunnyCdnService API key redaction in errors +- StorageUrlResolver configurable signed URL expiry +- *Remaining: Integration tests* + +### Config (4/4) +- ConfigService value type validation +- ConfigResolver max recursion depth +- Cache invalidation strategy documented + +### Console (3/3) +- InstallCommand credential masking +- InstallCommand rollback on failure +- Created MakeModCommand, MakePlugCommand, MakeWebsiteCommand + +### Crypt (3/3) +- LthnHash multi-key rotation support +- LthnHash MEDIUM_LENGTH and LONG_LENGTH options +- QuasiHash security documentation + +### Events (3/3) +- Event prioritization via array syntax +- EventAuditLog for replay/audit logging +- Dead letter queue via recordFailure() + +### Front (3/3) +- AdminMenuProvider permission checks +- Menu item caching with configurable TTL +- DynamicMenuProvider interface + +### Headers (3/3) +- CSP configurable, unsafe-inline only in dev +- Permissions-Policy header with 19 feature controls +- Environment-specific header configuration + +### Input (3/3) +- Schema-based per-field filter rules +- Unicode NFC normalisation +- Audit logging with PSR-3 logger + +### Lang (3/3) +- LangServiceProvider auto-discovery +- Fallback locale chain support +- Translation key validation + +### Mail (3/3) +- Disposable domain auto-update +- MX lookup caching +- Data retention cleanup command + +### Media (4/4) +- Local abstracts to remove Core\Mod\Social dependency +- Memory limit checks before image processing +- HEIC/AVIF format support + +### Search (3/3) +- Configurable API endpoints +- Search result caching +- Wildcard DoS protection + +### Seo (3/3) +- Schema validation against schema.org +- Sitemap generation (already existed) + +### Service (2/2) +- ServiceVersion with semver and deprecation +- HealthCheckable interface and HealthCheckResult + +### Storage (3/3) +- RedisFallbackActivated event +- CacheWarmer with registration system +- Configurable exception throwing + +--- + +## Medium Severity Issues Fixed + +- Bouncer pagination for large blocklists +- CDN URL building consistency, content-type detection, health check +- Config soft deletes, sensitive value encryption, ConfigProvider interface +- Console progress bar, --dry-run option +- Crypt fast hash with xxHash, benchmark method +- Events PHPDoc annotations, event versioning +- Front icon validation, menu priority constants +- Headers nonce-based CSP, configuration UI +- Input HTML subset for rich text, max length enforcement +- Lang pluralisation rules, ICU message format +- Mail async validation, email normalisation +- Media queued conversions, EXIF stripping, progressive JPEG +- Search scoring tuning, fuzzy search, analytics tracking +- SEO lazy schema loading, OG image validation, canonical conflict detection +- Service dependency declaration, discovery mechanism +- Storage circuit breaker, metrics collection + +--- + +## Low Severity Issues Fixed + +- Bouncer unit tests, configuration documentation +- CDN PHPDoc return types, CdnUrlBuilder extraction +- Config import/export, versioning for rollback +- Console autocompletion, colorized output +- Crypt algorithm documentation, constant-time comparison docs +- Events listener profiling, flow diagrams +- Front fluent menu builder, menu grouping +- Headers testing utilities, CSP documentation +- Input filter presets, transformation hooks +- Lang translation coverage reporting, translation memory +- Mail validation caching, disposable domain documentation +- Media progress reporting, lazy thumbnail generation +- Search suggestions/autocomplete, result highlighting +- SEO score trend tracking, structured data testing +- Service registration validation, lifecycle documentation +- Storage hit rate monitoring, multi-tier caching + +--- + +*Review performed by: Claude Opus 4.5 code review agents* +*Implementation: Claude Opus 4.5 fix agents (9 batches)* diff --git a/changelog/2026/jan/features.md b/changelog/2026/jan/features.md new file mode 100644 index 0000000..78ea1c9 --- /dev/null +++ b/changelog/2026/jan/features.md @@ -0,0 +1,163 @@ +# Core-PHP - January 2026 + +## Features Implemented + +### Actions Pattern + +`Core\Actions\Action` trait for single-purpose business logic classes. + +```php +use Core\Actions\Action; + +class CreateThing +{ + use Action; + + public function handle(User $user, array $data): Thing + { + // Complex business logic here + } +} + +// Usage +$thing = CreateThing::run($user, $data); +``` + +**Location:** `src/Core/Actions/Action.php`, `src/Core/Actions/Actionable.php` + +--- + +### Multi-Tenant Data Isolation + +**Files:** +- `MissingWorkspaceContextException` - Dedicated exception with factory methods +- `WorkspaceScope` - Strict mode enforcement, throws on missing context +- `BelongsToWorkspace` - Enhanced trait with context validation +- `RequireWorkspaceContext` middleware + +**Usage:** +```php +Account::query()->forWorkspace($workspace)->get(); +Account::query()->acrossWorkspaces()->get(); +WorkspaceScope::withoutStrictMode(fn() => Account::all()); +``` + +--- + +### Seeder Auto-Discovery + +**Files:** +- `src/Core/Database/Seeders/SeederDiscovery.php` - Scans modules for seeders +- `src/Core/Database/Seeders/SeederRegistry.php` - Manual registration +- `src/Core/Database/Seeders/CoreDatabaseSeeder.php` - Base class with --exclude/--only +- `src/Core/Database/Seeders/Attributes/` - SeederPriority, SeederAfter, SeederBefore + +**Usage:** +```php +class FeatureSeeder extends Seeder +{ + public int $priority = 10; + public function run(): void { ... } +} + +#[SeederAfter(FeatureSeeder::class)] +class PackageSeeder extends Seeder { ... } +``` + +**Config:** `core.seeders.auto_discover`, `core.seeders.paths`, `core.seeders.exclude` + +--- + +### Team-Scoped Caching + +**Files:** +- `src/Mod/Tenant/Services/WorkspaceCacheManager.php` - Cache management service +- `src/Mod/Tenant/Concerns/HasWorkspaceCache.php` - Trait for custom caching +- Enhanced `BelongsToWorkspace` trait + +**Usage:** +```php +$projects = Project::ownedByCurrentWorkspaceCached(300); +$accounts = Account::forWorkspaceCached($workspace, 600); +``` + +**Config:** `core.workspace_cache.enabled`, `core.workspace_cache.ttl`, `core.workspace_cache.use_tags` + +--- + +### Activity Logging + +**Files:** +- `src/Core/Activity/Concerns/LogsActivity.php` - Model trait +- `src/Core/Activity/Services/ActivityLogService.php` - Query service +- `src/Core/Activity/Models/Activity.php` - Extended model +- `src/Core/Activity/View/Modal/Admin/ActivityFeed.php` - Livewire component +- `src/Core/Activity/Console/ActivityPruneCommand.php` - Cleanup command + +**Usage:** +```php +use Core\Activity\Concerns\LogsActivity; + +class Post extends Model +{ + use LogsActivity; +} + +$activities = app(ActivityLogService::class) + ->logBy($user) + ->forWorkspace($workspace) + ->recent(20); +``` + +**Config:** `core.activity.enabled`, `core.activity.retention_days` + +**Requires:** `composer require spatie/laravel-activitylog` + +--- + +### Bouncer Request Whitelisting + +**Files:** +- `src/Core/Bouncer/Gate/Migrations/` - Database tables +- `src/Core/Bouncer/Gate/Models/ActionPermission.php` - Permission model +- `src/Core/Bouncer/Gate/Models/ActionRequest.php` - Audit log model +- `src/Core/Bouncer/Gate/ActionGateService.php` - Core service +- `src/Core/Bouncer/Gate/ActionGateMiddleware.php` - Middleware +- `src/Core/Bouncer/Gate/Attributes/Action.php` - Controller attribute +- `src/Core/Bouncer/Gate/RouteActionMacro.php` - Route macro + +**Usage:** +```php +// Route-level +Route::post('/products', [ProductController::class, 'store']) + ->action('product.create'); + +// Controller attribute +#[Action('product.delete', scope: 'product')] +public function destroy(Product $product) { ... } +``` + +**Config:** `core.bouncer.training_mode`, `core.bouncer.enabled` + +--- + +### CDN Integration Tests + +Comprehensive test suite for CDN operations and asset pipeline. + +**Files:** +- `src/Core/Tests/Feature/CdnIntegrationTest.php` - Full integration test suite + +**Coverage:** +- URL building (CDN, origin, private, apex) +- Asset pipeline (upload, store, delete) +- Storage operations (public/private buckets) +- vBucket isolation and path generation +- URL versioning and query parameters +- Signed URL generation +- Large file handling +- Special character handling in filenames +- Multi-file deletion +- File existence checks and metadata + +**Test count:** 30+ assertions across URL generation, storage, and retrieval diff --git a/changelog/2026/jan/traffic-detection-inAppBrowser.md b/changelog/2026/jan/traffic-detection-inAppBrowser.md new file mode 100644 index 0000000..7015409 --- /dev/null +++ b/changelog/2026/jan/traffic-detection-inAppBrowser.md @@ -0,0 +1,152 @@ +# In-App Browser Detection + +Detects when users visit from social media in-app browsers (Instagram, TikTok, etc.) rather than standard browsers. + +## Why this exists + +Creators sharing links on social platforms need to know when traffic comes from in-app browsers because: + +- **Content policies differ** - Some platforms deplatform users who link to adult content without warnings +- **User experience varies** - In-app browsers have limitations (no extensions, different cookie handling) +- **Traffic routing** - Creators may want to redirect certain platform traffic or show platform-specific messaging + +## Location + +``` +app/Services/Shared/DeviceDetectionService.php +``` + +## Basic usage + +```php +use App\Services\Shared\DeviceDetectionService; + +$dd = app(DeviceDetectionService::class); +$ua = request()->userAgent(); + +// Check for specific platforms +$dd->isInstagram($ua) // true if Instagram in-app browser +$dd->isFacebook($ua) // true if Facebook in-app browser +$dd->isTikTok($ua) // true if TikTok in-app browser +$dd->isTwitter($ua) // true if Twitter/X in-app browser +$dd->isSnapchat($ua) // true if Snapchat in-app browser +$dd->isLinkedIn($ua) // true if LinkedIn in-app browser +$dd->isThreads($ua) // true if Threads in-app browser +$dd->isPinterest($ua) // true if Pinterest in-app browser +$dd->isReddit($ua) // true if Reddit in-app browser + +// General checks +$dd->isInAppBrowser($ua) // true if ANY in-app browser +$dd->isMetaPlatform($ua) // true if Instagram, Facebook, or Threads +``` + +## Grouped platform checks + +### Strict content platforms + +Platforms known to enforce content policies that may result in account action: + +```php +$dd->isStrictContentPlatform($ua) +// Returns true for: Instagram, Facebook, Threads, TikTok, Twitter, Snapchat, LinkedIn +``` + +### Meta platforms + +All Meta-owned apps (useful for consistent policy application): + +```php +$dd->isMetaPlatform($ua) +// Returns true for: Instagram, Facebook, Threads +``` + +## Example: BioHost 18+ warning + +Show a content warning when adult content is accessed from strict platforms: + +```php +// In PublicBioPageController or Livewire component +$deviceDetection = app(DeviceDetectionService::class); + +$showAdultWarning = $biolink->is_adult_content + && $deviceDetection->isStrictContentPlatform(request()->userAgent()); + +// Or target a specific platform +$showInstagramWarning = $biolink->is_adult_content + && $deviceDetection->isInstagram(request()->userAgent()); +``` + +## Full device info + +The `parse()` method returns all detection data at once: + +```php +$dd->parse($ua); + +// Returns: +[ + 'device_type' => 'mobile', + 'os_name' => 'iOS', + 'browser_name' => null, // In-app browsers often lack browser identification + 'in_app_browser' => 'instagram', + 'is_in_app' => true, +] +``` + +## Display names + +Get human-readable platform names for UI display: + +```php +$dd->getPlatformDisplayName($ua); + +// Returns: "Instagram", "TikTok", "X (Twitter)", "LinkedIn", etc. +// Returns null if not an in-app browser +``` + +## Supported platforms + +| Platform | Method | In strict list | +|----------|--------|----------------| +| Instagram | `isInstagram()` | Yes | +| Facebook | `isFacebook()` | Yes | +| Threads | `isThreads()` | Yes | +| TikTok | `isTikTok()` | Yes | +| Twitter/X | `isTwitter()` | Yes | +| Snapchat | `isSnapchat()` | Yes | +| LinkedIn | `isLinkedIn()` | Yes | +| Pinterest | `isPinterest()` | No | +| Reddit | `isReddit()` | No | +| WeChat | via `detectInAppBrowser()` | No | +| LINE | via `detectInAppBrowser()` | No | +| Telegram | via `detectInAppBrowser()` | No | +| Discord | via `detectInAppBrowser()` | No | +| WhatsApp | via `detectInAppBrowser()` | No | +| Generic WebView | `isInAppBrowser()` | No | + +## How detection works + +Each platform adds identifiable strings to their in-app browser User-Agent: + +``` +Instagram: "Instagram" in UA +Facebook: "FBAN", "FBAV", "FB_IAB", "FBIOS", or "FBSS" +TikTok: "BytedanceWebview", "musical_ly", or "TikTok" +Twitter: "Twitter" in UA +LinkedIn: "LinkedInApp" +Snapchat: "Snapchat" +Threads: "Barcelona" (Meta's internal codename) +``` + +Generic WebView detection catches unknown in-app browsers via patterns like `wv` (Android WebView marker). + +## Related services + +This service is part of the shared services extracted for use across the platform: + +- `DeviceDetectionService` - Device type, OS, browser, bot detection, in-app browser detection +- `GeoIpService` - IP geolocation from CDN headers or MaxMind +- `PrivacyHelper` - IP anonymisation and hashing +- `UtmHelper` - UTM parameter extraction + +See also: `doc/dev-feat-docs/traffic-detections/` for other detection features. diff --git a/cmd.go b/cmd.go deleted file mode 100644 index 59ee99e..0000000 --- a/cmd.go +++ /dev/null @@ -1,157 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/io" - "github.com/spf13/cobra" -) - -// DefaultMedium is the default filesystem medium used by the php package. -// It defaults to io.Local (unsandboxed filesystem access). -// Use SetMedium to change this for testing or sandboxed operation. -var DefaultMedium io.Medium = io.Local - -// SetMedium sets the default medium for filesystem operations. -// This is primarily useful for testing with mock mediums. -func SetMedium(m io.Medium) { - DefaultMedium = m -} - -// getMedium returns the default medium for filesystem operations. -func getMedium() io.Medium { - return DefaultMedium -} - -func init() { - cli.RegisterCommands(AddPHPCommands) -} - -// Style aliases from shared -var ( - successStyle = cli.SuccessStyle - errorStyle = cli.ErrorStyle - dimStyle = cli.DimStyle - linkStyle = cli.LinkStyle -) - -// Service colors for log output (domain-specific, keep local) -var ( - phpFrankenPHPStyle = cli.NewStyle().Foreground(cli.ColourIndigo500) - phpViteStyle = cli.NewStyle().Foreground(cli.ColourYellow500) - phpHorizonStyle = cli.NewStyle().Foreground(cli.ColourOrange500) - phpReverbStyle = cli.NewStyle().Foreground(cli.ColourViolet500) - phpRedisStyle = cli.NewStyle().Foreground(cli.ColourRed500) -) - -// Status styles (from shared) -var ( - phpStatusRunning = cli.SuccessStyle - phpStatusStopped = cli.DimStyle - phpStatusError = cli.ErrorStyle -) - -// QA command styles (from shared) -var ( - phpQAPassedStyle = cli.SuccessStyle - phpQAFailedStyle = cli.ErrorStyle - phpQAWarningStyle = cli.WarningStyle - phpQAStageStyle = cli.HeaderStyle -) - -// Security severity styles (from shared) -var ( - phpSecurityCriticalStyle = cli.NewStyle().Bold().Foreground(cli.ColourRed500) - phpSecurityHighStyle = cli.NewStyle().Bold().Foreground(cli.ColourOrange500) - phpSecurityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500) - phpSecurityLowStyle = cli.NewStyle().Foreground(cli.ColourGray500) -) - -// AddPHPCommands adds PHP/Laravel development commands. -func AddPHPCommands(root *cobra.Command) { - phpCmd := &cobra.Command{ - Use: "php", - Short: i18n.T("cmd.php.short"), - Long: i18n.T("cmd.php.long"), - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Check if we are in a workspace root - wsRoot, err := findWorkspaceRoot() - if err != nil { - return nil // Not in a workspace, regular behavior - } - - // Load workspace config - config, err := loadWorkspaceConfig(wsRoot) - if err != nil || config == nil { - return nil // Failed to load or no config, ignore - } - - if config.Active == "" { - return nil // No active package - } - - // Calculate package path - pkgDir := config.PackagesDir - if pkgDir == "" { - pkgDir = "./packages" - } - if !filepath.IsAbs(pkgDir) { - pkgDir = filepath.Join(wsRoot, pkgDir) - } - - targetDir := filepath.Join(pkgDir, config.Active) - - // Check if target directory exists - if !getMedium().IsDir(targetDir) { - cli.Warnf("Active package directory not found: %s", targetDir) - return nil - } - - // Change working directory - if err := os.Chdir(targetDir); err != nil { - return cli.Err("failed to change directory to active package: %w", err) - } - - cli.Print("%s %s\n", dimStyle.Render("Workspace:"), config.Active) - return nil - }, - } - root.AddCommand(phpCmd) - - // Development - addPHPDevCommand(phpCmd) - addPHPLogsCommand(phpCmd) - addPHPStopCommand(phpCmd) - addPHPStatusCommand(phpCmd) - addPHPSSLCommand(phpCmd) - - // Build & Deploy - addPHPBuildCommand(phpCmd) - addPHPServeCommand(phpCmd) - addPHPShellCommand(phpCmd) - - // Quality (existing) - addPHPTestCommand(phpCmd) - addPHPFmtCommand(phpCmd) - addPHPStanCommand(phpCmd) - - // Quality (new) - addPHPPsalmCommand(phpCmd) - addPHPAuditCommand(phpCmd) - addPHPSecurityCommand(phpCmd) - addPHPQACommand(phpCmd) - addPHPRectorCommand(phpCmd) - addPHPInfectionCommand(phpCmd) - - // CI/CD Integration - addPHPCICommand(phpCmd) - - // Package Management - addPHPPackagesCommands(phpCmd) - - // Deployment - addPHPDeployCommands(phpCmd) -} diff --git a/cmd_build.go b/cmd_build.go deleted file mode 100644 index b8b7583..0000000 --- a/cmd_build.go +++ /dev/null @@ -1,291 +0,0 @@ -package php - -import ( - "context" - "errors" - "os" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -var ( - buildType string - buildImageName string - buildTag string - buildPlatform string - buildDockerfile string - buildOutputPath string - buildFormat string - buildTemplate string - buildNoCache bool -) - -func addPHPBuildCommand(parent *cobra.Command) { - buildCmd := &cobra.Command{ - Use: "build", - Short: i18n.T("cmd.php.build.short"), - Long: i18n.T("cmd.php.build.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - ctx := context.Background() - - switch strings.ToLower(buildType) { - case "linuxkit": - return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{ - OutputPath: buildOutputPath, - Format: buildFormat, - Template: buildTemplate, - }) - default: - return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{ - ImageName: buildImageName, - Tag: buildTag, - Platform: buildPlatform, - Dockerfile: buildDockerfile, - NoCache: buildNoCache, - }) - } - }, - } - - buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.php.build.flag.type")) - buildCmd.Flags().StringVar(&buildImageName, "name", "", i18n.T("cmd.php.build.flag.name")) - buildCmd.Flags().StringVar(&buildTag, "tag", "", i18n.T("common.flag.tag")) - buildCmd.Flags().StringVar(&buildPlatform, "platform", "", i18n.T("cmd.php.build.flag.platform")) - buildCmd.Flags().StringVar(&buildDockerfile, "dockerfile", "", i18n.T("cmd.php.build.flag.dockerfile")) - buildCmd.Flags().StringVar(&buildOutputPath, "output", "", i18n.T("cmd.php.build.flag.output")) - buildCmd.Flags().StringVar(&buildFormat, "format", "", i18n.T("cmd.php.build.flag.format")) - buildCmd.Flags().StringVar(&buildTemplate, "template", "", i18n.T("cmd.php.build.flag.template")) - buildCmd.Flags().BoolVar(&buildNoCache, "no-cache", false, i18n.T("cmd.php.build.flag.no_cache")) - - parent.AddCommand(buildCmd) -} - -type dockerBuildOptions struct { - ImageName string - Tag string - Platform string - Dockerfile string - NoCache bool -} - -type linuxKitBuildOptions struct { - OutputPath string - Format string - Template string -} - -func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildOptions) error { - if !IsPHPProject(projectDir) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_docker")) - - // Show detected configuration - config, err := DetectDockerfileConfig(projectDir) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.detect", "project configuration"), err) - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.php_version")), config.PHPVersion) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.laravel")), config.IsLaravel) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.octane")), config.HasOctane) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.frontend")), config.HasAssets) - if len(config.PHPExtensions) > 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.extensions")), strings.Join(config.PHPExtensions, ", ")) - } - cli.Blank() - - // Build options - buildOpts := DockerBuildOptions{ - ProjectDir: projectDir, - ImageName: opts.ImageName, - Tag: opts.Tag, - Platform: opts.Platform, - Dockerfile: opts.Dockerfile, - NoBuildCache: opts.NoCache, - Output: os.Stdout, - } - - if buildOpts.ImageName == "" { - buildOpts.ImageName = GetLaravelAppName(projectDir) - if buildOpts.ImageName == "" { - buildOpts.ImageName = "php-app" - } - // Sanitize for Docker - buildOpts.ImageName = strings.ToLower(strings.ReplaceAll(buildOpts.ImageName, " ", "-")) - } - - if buildOpts.Tag == "" { - buildOpts.Tag = "latest" - } - - cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), buildOpts.ImageName, buildOpts.Tag) - if opts.Platform != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.platform")), opts.Platform) - } - cli.Blank() - - if err := BuildDocker(ctx, buildOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Docker image built"})) - cli.Print("%s docker run -p 80:80 -p 443:443 %s:%s\n", - dimStyle.Render(i18n.T("cmd.php.build.docker_run_with")), - buildOpts.ImageName, buildOpts.Tag) - - return nil -} - -func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error { - if !IsPHPProject(projectDir) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_linuxkit")) - - buildOpts := LinuxKitBuildOptions{ - ProjectDir: projectDir, - OutputPath: opts.OutputPath, - Format: opts.Format, - Template: opts.Template, - Output: os.Stdout, - } - - if buildOpts.Format == "" { - buildOpts.Format = "qcow2" - } - if buildOpts.Template == "" { - buildOpts.Template = "server-php" - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("template")), buildOpts.Template) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.format")), buildOpts.Format) - cli.Blank() - - if err := BuildLinuxKit(ctx, buildOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "LinuxKit image built"})) - return nil -} - -var ( - serveImageName string - serveTag string - serveContainerName string - servePort int - serveHTTPSPort int - serveDetach bool - serveEnvFile string -) - -func addPHPServeCommand(parent *cobra.Command) { - serveCmd := &cobra.Command{ - Use: "serve", - Short: i18n.T("cmd.php.serve.short"), - Long: i18n.T("cmd.php.serve.long"), - RunE: func(cmd *cobra.Command, args []string) error { - imageName := serveImageName - if imageName == "" { - // Try to detect from current directory - cwd, err := os.Getwd() - if err == nil { - imageName = GetLaravelAppName(cwd) - if imageName != "" { - imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-")) - } - } - if imageName == "" { - return errors.New(i18n.T("cmd.php.serve.name_required")) - } - } - - ctx := context.Background() - - opts := ServeOptions{ - ImageName: imageName, - Tag: serveTag, - ContainerName: serveContainerName, - Port: servePort, - HTTPSPort: serveHTTPSPort, - Detach: serveDetach, - EnvFile: serveEnvFile, - Output: os.Stdout, - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "production container")) - cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), imageName, func() string { - if serveTag == "" { - return "latest" - } - return serveTag - }()) - - effectivePort := servePort - if effectivePort == 0 { - effectivePort = 80 - } - effectiveHTTPSPort := serveHTTPSPort - if effectiveHTTPSPort == 0 { - effectiveHTTPSPort = 443 - } - - cli.Print("%s http://localhost:%d, https://localhost:%d\n", - dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort) - cli.Blank() - - if err := ServeProduction(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.start", "container"), err) - } - - if !serveDetach { - cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.serve.stopped")) - } - - return nil - }, - } - - serveCmd.Flags().StringVar(&serveImageName, "name", "", i18n.T("cmd.php.serve.flag.name")) - serveCmd.Flags().StringVar(&serveTag, "tag", "", i18n.T("common.flag.tag")) - serveCmd.Flags().StringVar(&serveContainerName, "container", "", i18n.T("cmd.php.serve.flag.container")) - serveCmd.Flags().IntVar(&servePort, "port", 0, i18n.T("cmd.php.serve.flag.port")) - serveCmd.Flags().IntVar(&serveHTTPSPort, "https-port", 0, i18n.T("cmd.php.serve.flag.https_port")) - serveCmd.Flags().BoolVarP(&serveDetach, "detach", "d", false, i18n.T("cmd.php.serve.flag.detach")) - serveCmd.Flags().StringVar(&serveEnvFile, "env-file", "", i18n.T("cmd.php.serve.flag.env_file")) - - parent.AddCommand(serveCmd) -} - -func addPHPShellCommand(parent *cobra.Command) { - shellCmd := &cobra.Command{ - Use: "shell [container]", - Short: i18n.T("cmd.php.shell.short"), - Long: i18n.T("cmd.php.shell.long"), - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.shell.opening", map[string]interface{}{"Container": args[0]})) - - if err := Shell(ctx, args[0]); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.open", "shell"), err) - } - - return nil - }, - } - - parent.AddCommand(shellCmd) -} diff --git a/cmd_ci.go b/cmd_ci.go deleted file mode 100644 index 1c4344f..0000000 --- a/cmd_ci.go +++ /dev/null @@ -1,562 +0,0 @@ -// cmd_ci.go implements the 'php ci' command for CI/CD pipeline integration. -// -// Usage: -// core php ci # Run full CI pipeline -// core php ci --json # Output combined JSON report -// core php ci --summary # Output markdown summary -// core php ci --sarif # Generate SARIF files -// core php ci --upload-sarif # Upload SARIF to GitHub Security -// core php ci --fail-on=high # Only fail on high+ severity - -package php - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -// CI command flags -var ( - ciJSON bool - ciSummary bool - ciSARIF bool - ciUploadSARIF bool - ciFailOn string -) - -// CIResult represents the overall CI pipeline result -type CIResult struct { - Passed bool `json:"passed"` - ExitCode int `json:"exit_code"` - Duration string `json:"duration"` - StartedAt time.Time `json:"started_at"` - Checks []CICheckResult `json:"checks"` - Summary CISummary `json:"summary"` - Artifacts []string `json:"artifacts,omitempty"` -} - -// CICheckResult represents an individual check result -type CICheckResult struct { - Name string `json:"name"` - Status string `json:"status"` // passed, failed, warning, skipped - Duration string `json:"duration"` - Details string `json:"details,omitempty"` - Issues int `json:"issues,omitempty"` - Errors int `json:"errors,omitempty"` - Warnings int `json:"warnings,omitempty"` -} - -// CISummary contains aggregate statistics -type CISummary struct { - Total int `json:"total"` - Passed int `json:"passed"` - Failed int `json:"failed"` - Warnings int `json:"warnings"` - Skipped int `json:"skipped"` -} - -func addPHPCICommand(parent *cobra.Command) { - ciCmd := &cobra.Command{ - Use: "ci", - Short: i18n.T("cmd.php.ci.short"), - Long: i18n.T("cmd.php.ci.long"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPCI() - }, - } - - ciCmd.Flags().BoolVar(&ciJSON, "json", false, i18n.T("cmd.php.ci.flag.json")) - ciCmd.Flags().BoolVar(&ciSummary, "summary", false, i18n.T("cmd.php.ci.flag.summary")) - ciCmd.Flags().BoolVar(&ciSARIF, "sarif", false, i18n.T("cmd.php.ci.flag.sarif")) - ciCmd.Flags().BoolVar(&ciUploadSARIF, "upload-sarif", false, i18n.T("cmd.php.ci.flag.upload_sarif")) - ciCmd.Flags().StringVar(&ciFailOn, "fail-on", "error", i18n.T("cmd.php.ci.flag.fail_on")) - - parent.AddCommand(ciCmd) -} - -func runPHPCI() error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - startTime := time.Now() - ctx := context.Background() - - // Define checks to run in order - checks := []struct { - name string - run func(context.Context, string) (CICheckResult, error) - sarif bool // Whether this check can generate SARIF - }{ - {"test", runCITest, false}, - {"stan", runCIStan, true}, - {"psalm", runCIPsalm, true}, - {"fmt", runCIFmt, false}, - {"audit", runCIAudit, false}, - {"security", runCISecurity, false}, - } - - result := CIResult{ - StartedAt: startTime, - Passed: true, - Checks: make([]CICheckResult, 0, len(checks)), - } - - var artifacts []string - - // Print header unless JSON output - if !ciJSON { - cli.Print("\n%s\n", cli.BoldStyle.Render("core php ci - QA Pipeline")) - cli.Print("%s\n\n", strings.Repeat("─", 40)) - } - - // Run each check - for _, check := range checks { - if !ciJSON { - cli.Print(" %s %s...", dimStyle.Render("→"), check.name) - } - - checkResult, err := check.run(ctx, cwd) - if err != nil { - checkResult = CICheckResult{ - Name: check.name, - Status: "failed", - Details: err.Error(), - } - } - - result.Checks = append(result.Checks, checkResult) - - // Update summary - result.Summary.Total++ - switch checkResult.Status { - case "passed": - result.Summary.Passed++ - case "failed": - result.Summary.Failed++ - if shouldFailOn(checkResult, ciFailOn) { - result.Passed = false - } - case "warning": - result.Summary.Warnings++ - case "skipped": - result.Summary.Skipped++ - } - - // Print result - if !ciJSON { - cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), check.name, dimStyle.Render(checkResult.Details)) - } - - // Generate SARIF if requested - if (ciSARIF || ciUploadSARIF) && check.sarif { - sarifFile := filepath.Join(cwd, check.name+".sarif") - if generateSARIF(ctx, cwd, check.name, sarifFile) == nil { - artifacts = append(artifacts, sarifFile) - } - } - } - - result.Duration = time.Since(startTime).Round(time.Millisecond).String() - result.Artifacts = artifacts - - // Set exit code - if result.Passed { - result.ExitCode = 0 - } else { - result.ExitCode = 1 - } - - // Output based on flags - if ciJSON { - if err := outputCIJSON(result); err != nil { - return err - } - if !result.Passed { - return cli.Exit(result.ExitCode, cli.Err("CI pipeline failed")) - } - return nil - } - - if ciSummary { - if err := outputCISummary(result); err != nil { - return err - } - if !result.Passed { - return cli.Err("CI pipeline failed") - } - return nil - } - - // Default table output - cli.Print("\n%s\n", strings.Repeat("─", 40)) - - if result.Passed { - cli.Print("%s %s\n", successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration)) - } else { - cli.Print("%s %s\n", errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration)) - } - - if len(artifacts) > 0 { - cli.Print("\n%s\n", dimStyle.Render("Artifacts:")) - for _, a := range artifacts { - cli.Print(" → %s\n", filepath.Base(a)) - } - } - - // Upload SARIF if requested - if ciUploadSARIF && len(artifacts) > 0 { - cli.Blank() - for _, sarifFile := range artifacts { - if err := uploadSARIFToGitHub(ctx, sarifFile); err != nil { - cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), filepath.Base(sarifFile), err) - } else { - cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), filepath.Base(sarifFile)) - } - } - } - - if !result.Passed { - return cli.Err("CI pipeline failed") - } - return nil -} - -// runCITest runs Pest/PHPUnit tests -func runCITest(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "test", Status: "passed"} - - opts := TestOptions{ - Dir: dir, - Output: nil, // Suppress output - } - - if err := RunTests(ctx, opts); err != nil { - result.Status = "failed" - result.Details = err.Error() - } else { - result.Details = "all tests passed" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIStan runs PHPStan -func runCIStan(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "stan", Status: "passed"} - - _, found := DetectAnalyser(dir) - if !found { - result.Status = "skipped" - result.Details = "PHPStan not configured" - return result, nil - } - - opts := AnalyseOptions{ - Dir: dir, - Output: nil, - } - - if err := Analyse(ctx, opts); err != nil { - result.Status = "failed" - result.Details = "errors found" - } else { - result.Details = "0 errors" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIPsalm runs Psalm -func runCIPsalm(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "psalm", Status: "passed"} - - _, found := DetectPsalm(dir) - if !found { - result.Status = "skipped" - result.Details = "Psalm not configured" - return result, nil - } - - opts := PsalmOptions{ - Dir: dir, - Output: nil, - } - - if err := RunPsalm(ctx, opts); err != nil { - result.Status = "failed" - result.Details = "errors found" - } else { - result.Details = "0 errors" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIFmt checks code formatting -func runCIFmt(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "fmt", Status: "passed"} - - _, found := DetectFormatter(dir) - if !found { - result.Status = "skipped" - result.Details = "no formatter configured" - return result, nil - } - - opts := FormatOptions{ - Dir: dir, - Fix: false, // Check only - Output: nil, - } - - if err := Format(ctx, opts); err != nil { - result.Status = "warning" - result.Details = "formatting issues" - } else { - result.Details = "code style OK" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIAudit runs composer audit -func runCIAudit(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "audit", Status: "passed"} - - results, err := RunAudit(ctx, AuditOptions{ - Dir: dir, - Output: nil, - }) - if err != nil { - result.Status = "failed" - result.Details = err.Error() - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil - } - - totalVulns := 0 - for _, r := range results { - totalVulns += r.Vulnerabilities - } - - if totalVulns > 0 { - result.Status = "failed" - result.Details = fmt.Sprintf("%d vulnerabilities", totalVulns) - result.Issues = totalVulns - } else { - result.Details = "no vulnerabilities" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCISecurity runs security checks -func runCISecurity(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "security", Status: "passed"} - - secResult, err := RunSecurityChecks(ctx, SecurityOptions{ - Dir: dir, - Output: nil, - }) - if err != nil { - result.Status = "failed" - result.Details = err.Error() - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil - } - - if secResult.Summary.Critical > 0 || secResult.Summary.High > 0 { - result.Status = "failed" - result.Details = fmt.Sprintf("%d critical, %d high", secResult.Summary.Critical, secResult.Summary.High) - result.Issues = secResult.Summary.Critical + secResult.Summary.High - } else if secResult.Summary.Medium > 0 { - result.Status = "warning" - result.Details = fmt.Sprintf("%d medium issues", secResult.Summary.Medium) - result.Warnings = secResult.Summary.Medium - } else { - result.Details = "no issues" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// shouldFailOn determines if a check should cause CI failure based on --fail-on -func shouldFailOn(check CICheckResult, level string) bool { - switch level { - case "critical": - return check.Status == "failed" && check.Issues > 0 - case "high", "error": - return check.Status == "failed" - case "warning": - return check.Status == "failed" || check.Status == "warning" - default: - return check.Status == "failed" - } -} - -// getStatusIcon returns the icon for a check status -func getStatusIcon(status string) string { - switch status { - case "passed": - return successStyle.Render("✓") - case "failed": - return errorStyle.Render("✗") - case "warning": - return phpQAWarningStyle.Render("⚠") - case "skipped": - return dimStyle.Render("-") - default: - return dimStyle.Render("?") - } -} - -// outputCIJSON outputs the result as JSON -func outputCIJSON(result CIResult) error { - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - fmt.Println(string(data)) - return nil -} - -// outputCISummary outputs a markdown summary -func outputCISummary(result CIResult) error { - var sb strings.Builder - - sb.WriteString("## CI Pipeline Results\n\n") - - if result.Passed { - sb.WriteString("**Status:** ✅ Passed\n\n") - } else { - sb.WriteString("**Status:** ❌ Failed\n\n") - } - - sb.WriteString("| Check | Status | Details |\n") - sb.WriteString("|-------|--------|----------|\n") - - for _, check := range result.Checks { - icon := "✅" - switch check.Status { - case "failed": - icon = "❌" - case "warning": - icon = "⚠️" - case "skipped": - icon = "⏭️" - } - sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", check.Name, icon, check.Details)) - } - - sb.WriteString(fmt.Sprintf("\n**Duration:** %s\n", result.Duration)) - - fmt.Print(sb.String()) - return nil -} - -// generateSARIF generates a SARIF file for a specific check -func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error { - var args []string - - switch checkName { - case "stan": - args = []string{"vendor/bin/phpstan", "analyse", "--error-format=sarif", "--no-progress"} - case "psalm": - args = []string{"vendor/bin/psalm", "--output-format=sarif"} - default: - return fmt.Errorf("SARIF not supported for %s", checkName) - } - - cmd := exec.CommandContext(ctx, "php", args...) - cmd.Dir = dir - - // Capture output - command may exit non-zero when issues are found - // but still produce valid SARIF output - output, err := cmd.CombinedOutput() - if len(output) == 0 { - if err != nil { - return fmt.Errorf("failed to generate SARIF: %w", err) - } - return fmt.Errorf("no SARIF output generated") - } - - // Validate output is valid JSON - var js json.RawMessage - if err := json.Unmarshal(output, &js); err != nil { - return fmt.Errorf("invalid SARIF output: %w", err) - } - - return getMedium().Write(outputFile, string(output)) -} - -// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab -func uploadSARIFToGitHub(ctx context.Context, sarifFile string) error { - // Validate commit SHA before calling API - sha := getGitSHA() - if sha == "" { - return errors.New("cannot upload SARIF: git commit SHA not available (ensure you're in a git repository)") - } - - // Use gh CLI to upload - cmd := exec.CommandContext(ctx, "gh", "api", - "repos/{owner}/{repo}/code-scanning/sarifs", - "-X", "POST", - "-F", "sarif=@"+sarifFile, - "-F", "ref="+getGitRef(), - "-F", "commit_sha="+sha, - ) - - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%s: %s", err, string(output)) - } - return nil -} - -// getGitRef returns the current git ref -func getGitRef() string { - cmd := exec.Command("git", "symbolic-ref", "HEAD") - output, err := cmd.Output() - if err != nil { - return "refs/heads/main" - } - return strings.TrimSpace(string(output)) -} - -// getGitSHA returns the current git commit SHA -func getGitSHA() string { - cmd := exec.Command("git", "rev-parse", "HEAD") - output, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) -} diff --git a/cmd_commands.go b/cmd_commands.go deleted file mode 100644 index c0a2444..0000000 --- a/cmd_commands.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package php provides Laravel/PHP development and deployment commands. -// -// Development Commands: -// - dev: Start Laravel environment (FrankenPHP, Vite, Horizon, Reverb, Redis) -// - logs: Stream unified service logs -// - stop: Stop all running services -// - status: Show service status -// - ssl: Setup SSL certificates with mkcert -// -// Build Commands: -// - build: Build Docker or LinuxKit image -// - serve: Run production container -// - shell: Open shell in running container -// -// Code Quality: -// - test: Run PHPUnit/Pest tests -// - fmt: Format code with Laravel Pint -// - stan: Run PHPStan/Larastan static analysis -// - psalm: Run Psalm static analysis -// - audit: Security audit for dependencies -// - security: Security vulnerability scanning -// - qa: Run full QA pipeline -// - rector: Automated code refactoring -// - infection: Mutation testing for test quality -// -// Package Management: -// - packages link/unlink/update/list: Manage local Composer packages -// -// Deployment (Coolify): -// - deploy: Deploy to Coolify -// - deploy:status: Check deployment status -// - deploy:rollback: Rollback deployment -// - deploy:list: List recent deployments -package php - -import "github.com/spf13/cobra" - -// AddCommands registers the 'php' command and all subcommands. -func AddCommands(root *cobra.Command) { - AddPHPCommands(root) -} diff --git a/cmd_deploy.go b/cmd_deploy.go deleted file mode 100644 index 2298a43..0000000 --- a/cmd_deploy.go +++ /dev/null @@ -1,361 +0,0 @@ -package php - -import ( - "context" - "os" - "time" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -// Deploy command styles (aliases to shared) -var ( - phpDeployStyle = cli.SuccessStyle - phpDeployPendingStyle = cli.WarningStyle - phpDeployFailedStyle = cli.ErrorStyle -) - -func addPHPDeployCommands(parent *cobra.Command) { - // Main deploy command - addPHPDeployCommand(parent) - - // Deploy status subcommand (using colon notation: deploy:status) - addPHPDeployStatusCommand(parent) - - // Deploy rollback subcommand - addPHPDeployRollbackCommand(parent) - - // Deploy list subcommand - addPHPDeployListCommand(parent) -} - -var ( - deployStaging bool - deployForce bool - deployWait bool -) - -func addPHPDeployCommand(parent *cobra.Command) { - deployCmd := &cobra.Command{ - Use: "deploy", - Short: i18n.T("cmd.php.deploy.short"), - Long: i18n.T("cmd.php.deploy.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if deployStaging { - env = EnvStaging - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy.deploying", map[string]interface{}{"Environment": env})) - - ctx := context.Background() - - opts := DeployOptions{ - Dir: cwd, - Environment: env, - Force: deployForce, - Wait: deployWait, - } - - status, err := Deploy(ctx, opts) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.deploy_failed"), err) - } - - printDeploymentStatus(status) - - if deployWait { - if IsDeploymentSuccessful(status.Status) { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Deployment completed"})) - } else { - cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy.warning_status", map[string]interface{}{"Status": status.Status})) - } - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy.triggered")) - } - - return nil - }, - } - - deployCmd.Flags().BoolVar(&deployStaging, "staging", false, i18n.T("cmd.php.deploy.flag.staging")) - deployCmd.Flags().BoolVar(&deployForce, "force", false, i18n.T("cmd.php.deploy.flag.force")) - deployCmd.Flags().BoolVar(&deployWait, "wait", false, i18n.T("cmd.php.deploy.flag.wait")) - - parent.AddCommand(deployCmd) -} - -var ( - deployStatusStaging bool - deployStatusDeploymentID string -) - -func addPHPDeployStatusCommand(parent *cobra.Command) { - statusCmd := &cobra.Command{ - Use: "deploy:status", - Short: i18n.T("cmd.php.deploy_status.short"), - Long: i18n.T("cmd.php.deploy_status.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if deployStatusStaging { - env = EnvStaging - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.ProgressSubject("check", "deployment status")) - - ctx := context.Background() - - opts := StatusOptions{ - Dir: cwd, - Environment: env, - DeploymentID: deployStatusDeploymentID, - } - - status, err := DeployStatus(ctx, opts) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "status"), err) - } - - printDeploymentStatus(status) - - return nil - }, - } - - statusCmd.Flags().BoolVar(&deployStatusStaging, "staging", false, i18n.T("cmd.php.deploy_status.flag.staging")) - statusCmd.Flags().StringVar(&deployStatusDeploymentID, "id", "", i18n.T("cmd.php.deploy_status.flag.id")) - - parent.AddCommand(statusCmd) -} - -var ( - rollbackStaging bool - rollbackDeploymentID string - rollbackWait bool -) - -func addPHPDeployRollbackCommand(parent *cobra.Command) { - rollbackCmd := &cobra.Command{ - Use: "deploy:rollback", - Short: i18n.T("cmd.php.deploy_rollback.short"), - Long: i18n.T("cmd.php.deploy_rollback.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if rollbackStaging { - env = EnvStaging - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_rollback.rolling_back", map[string]interface{}{"Environment": env})) - - ctx := context.Background() - - opts := RollbackOptions{ - Dir: cwd, - Environment: env, - DeploymentID: rollbackDeploymentID, - Wait: rollbackWait, - } - - status, err := Rollback(ctx, opts) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.rollback_failed"), err) - } - - printDeploymentStatus(status) - - if rollbackWait { - if IsDeploymentSuccessful(status.Status) { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Rollback completed"})) - } else { - cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy_rollback.warning_status", map[string]interface{}{"Status": status.Status})) - } - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy_rollback.triggered")) - } - - return nil - }, - } - - rollbackCmd.Flags().BoolVar(&rollbackStaging, "staging", false, i18n.T("cmd.php.deploy_rollback.flag.staging")) - rollbackCmd.Flags().StringVar(&rollbackDeploymentID, "id", "", i18n.T("cmd.php.deploy_rollback.flag.id")) - rollbackCmd.Flags().BoolVar(&rollbackWait, "wait", false, i18n.T("cmd.php.deploy_rollback.flag.wait")) - - parent.AddCommand(rollbackCmd) -} - -var ( - deployListStaging bool - deployListLimit int -) - -func addPHPDeployListCommand(parent *cobra.Command) { - listCmd := &cobra.Command{ - Use: "deploy:list", - Short: i18n.T("cmd.php.deploy_list.short"), - Long: i18n.T("cmd.php.deploy_list.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if deployListStaging { - env = EnvStaging - } - - limit := deployListLimit - if limit == 0 { - limit = 10 - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_list.recent", map[string]interface{}{"Environment": env})) - - ctx := context.Background() - - deployments, err := ListDeployments(ctx, cwd, env, limit) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.list", "deployments"), err) - } - - if len(deployments) == 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.deploy_list.none_found")) - return nil - } - - for i, d := range deployments { - printDeploymentSummary(i+1, &d) - } - - return nil - }, - } - - listCmd.Flags().BoolVar(&deployListStaging, "staging", false, i18n.T("cmd.php.deploy_list.flag.staging")) - listCmd.Flags().IntVar(&deployListLimit, "limit", 0, i18n.T("cmd.php.deploy_list.flag.limit")) - - parent.AddCommand(listCmd) -} - -func printDeploymentStatus(status *DeploymentStatus) { - // Status with color - statusStyle := phpDeployStyle - switch status.Status { - case "queued", "building", "deploying", "pending", "rolling_back": - statusStyle = phpDeployPendingStyle - case "failed", "error", "cancelled": - statusStyle = phpDeployFailedStyle - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), statusStyle.Render(status.Status)) - - if status.ID != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.id")), status.ID) - } - - if status.URL != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("url")), linkStyle.Render(status.URL)) - } - - if status.Branch != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.branch")), status.Branch) - } - - if status.Commit != "" { - commit := status.Commit - if len(commit) > 7 { - commit = commit[:7] - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.commit")), commit) - if status.CommitMessage != "" { - // Truncate long messages - msg := status.CommitMessage - if len(msg) > 60 { - msg = msg[:57] + "..." - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.message")), msg) - } - } - - if !status.StartedAt.IsZero() { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("started")), status.StartedAt.Format(time.RFC3339)) - } - - if !status.CompletedAt.IsZero() { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.completed")), status.CompletedAt.Format(time.RFC3339)) - if !status.StartedAt.IsZero() { - duration := status.CompletedAt.Sub(status.StartedAt) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.duration")), duration.Round(time.Second)) - } - } -} - -func printDeploymentSummary(index int, status *DeploymentStatus) { - // Status with color - statusStyle := phpDeployStyle - switch status.Status { - case "queued", "building", "deploying", "pending", "rolling_back": - statusStyle = phpDeployPendingStyle - case "failed", "error", "cancelled": - statusStyle = phpDeployFailedStyle - } - - // Format: #1 [finished] abc1234 - commit message (2 hours ago) - id := status.ID - if len(id) > 8 { - id = id[:8] - } - - commit := status.Commit - if len(commit) > 7 { - commit = commit[:7] - } - - msg := status.CommitMessage - if len(msg) > 40 { - msg = msg[:37] + "..." - } - - age := "" - if !status.StartedAt.IsZero() { - age = i18n.TimeAgo(status.StartedAt) - } - - cli.Print(" %s %s %s", - dimStyle.Render(cli.Sprintf("#%d", index)), - statusStyle.Render(cli.Sprintf("[%s]", status.Status)), - id, - ) - - if commit != "" { - cli.Print(" %s", commit) - } - - if msg != "" { - cli.Print(" - %s", msg) - } - - if age != "" { - cli.Print(" %s", dimStyle.Render(cli.Sprintf("(%s)", age))) - } - - cli.Blank() -} diff --git a/cmd_dev.go b/cmd_dev.go deleted file mode 100644 index d2d8de0..0000000 --- a/cmd_dev.go +++ /dev/null @@ -1,497 +0,0 @@ -package php - -import ( - "bufio" - "context" - "errors" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -var ( - devNoVite bool - devNoHorizon bool - devNoReverb bool - devNoRedis bool - devHTTPS bool - devDomain string - devPort int -) - -func addPHPDevCommand(parent *cobra.Command) { - devCmd := &cobra.Command{ - Use: "dev", - Short: i18n.T("cmd.php.dev.short"), - Long: i18n.T("cmd.php.dev.long"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPDev(phpDevOptions{ - NoVite: devNoVite, - NoHorizon: devNoHorizon, - NoReverb: devNoReverb, - NoRedis: devNoRedis, - HTTPS: devHTTPS, - Domain: devDomain, - Port: devPort, - }) - }, - } - - devCmd.Flags().BoolVar(&devNoVite, "no-vite", false, i18n.T("cmd.php.dev.flag.no_vite")) - devCmd.Flags().BoolVar(&devNoHorizon, "no-horizon", false, i18n.T("cmd.php.dev.flag.no_horizon")) - devCmd.Flags().BoolVar(&devNoReverb, "no-reverb", false, i18n.T("cmd.php.dev.flag.no_reverb")) - devCmd.Flags().BoolVar(&devNoRedis, "no-redis", false, i18n.T("cmd.php.dev.flag.no_redis")) - devCmd.Flags().BoolVar(&devHTTPS, "https", false, i18n.T("cmd.php.dev.flag.https")) - devCmd.Flags().StringVar(&devDomain, "domain", "", i18n.T("cmd.php.dev.flag.domain")) - devCmd.Flags().IntVar(&devPort, "port", 0, i18n.T("cmd.php.dev.flag.port")) - - parent.AddCommand(devCmd) -} - -type phpDevOptions struct { - NoVite bool - NoHorizon bool - NoReverb bool - NoRedis bool - HTTPS bool - Domain string - Port int -} - -func runPHPDev(opts phpDevOptions) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("failed to get working directory: %w", err) - } - - // Check if this is a Laravel project - if !IsLaravelProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_laravel")) - } - - // Get app name for display - appName := GetLaravelAppName(cwd) - if appName == "" { - appName = "Laravel" - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.starting", map[string]interface{}{"AppName": appName})) - - // Detect services - services := DetectServices(cwd) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.services")), i18n.T("cmd.php.dev.detected_services")) - for _, svc := range services { - cli.Print(" %s %s\n", successStyle.Render("*"), svc) - } - cli.Blank() - - // Setup options - port := opts.Port - if port == 0 { - port = 8000 - } - - devOpts := Options{ - Dir: cwd, - NoVite: opts.NoVite, - NoHorizon: opts.NoHorizon, - NoReverb: opts.NoReverb, - NoRedis: opts.NoRedis, - HTTPS: opts.HTTPS, - Domain: opts.Domain, - FrankenPHPPort: port, - } - - // Create and start dev server - server := NewDevServer(devOpts) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Handle shutdown signals - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigCh - cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.shutting_down")) - cancel() - }() - - if err := server.Start(ctx, devOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.start", "services"), err) - } - - // Print status - cli.Print("%s %s\n", successStyle.Render(i18n.T("cmd.php.label.running")), i18n.T("cmd.php.dev.services_started")) - printServiceStatuses(server.Status()) - cli.Blank() - - // Print URLs - appURL := GetLaravelAppURL(cwd) - if appURL == "" { - if opts.HTTPS { - appURL = cli.Sprintf("https://localhost:%d", port) - } else { - appURL = cli.Sprintf("http://localhost:%d", port) - } - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.app_url")), linkStyle.Render(appURL)) - - // Check for Vite - if !opts.NoVite && containsService(services, ServiceVite) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.vite")), linkStyle.Render("http://localhost:5173")) - } - - cli.Print("\n%s\n\n", dimStyle.Render(i18n.T("cmd.php.dev.press_ctrl_c"))) - - // Stream unified logs - logsReader, err := server.Logs("", true) - if err != nil { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs")) - } else { - defer func() { _ = logsReader.Close() }() - - scanner := bufio.NewScanner(logsReader) - for scanner.Scan() { - select { - case <-ctx.Done(): - goto shutdown - default: - line := scanner.Text() - printColoredLog(line) - } - } - } - -shutdown: - // Stop services - if err := server.Stop(); err != nil { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.dev.stop_error", map[string]interface{}{"Error": err})) - } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped")) - return nil -} - -var ( - logsFollow bool - logsService string -) - -func addPHPLogsCommand(parent *cobra.Command) { - logsCmd := &cobra.Command{ - Use: "logs", - Short: i18n.T("cmd.php.logs.short"), - Long: i18n.T("cmd.php.logs.long"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPLogs(logsService, logsFollow) - }, - } - - logsCmd.Flags().BoolVar(&logsFollow, "follow", false, i18n.T("common.flag.follow")) - logsCmd.Flags().StringVar(&logsService, "service", "", i18n.T("cmd.php.logs.flag.service")) - - parent.AddCommand(logsCmd) -} - -func runPHPLogs(service string, follow bool) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - if !IsLaravelProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_laravel_short")) - } - - // Create a minimal server just to access logs - server := NewDevServer(Options{Dir: cwd}) - - logsReader, err := server.Logs(service, follow) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "logs"), err) - } - defer func() { _ = logsReader.Close() }() - - // Handle interrupt - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigCh - cancel() - }() - - scanner := bufio.NewScanner(logsReader) - for scanner.Scan() { - select { - case <-ctx.Done(): - return nil - default: - printColoredLog(scanner.Text()) - } - } - - return scanner.Err() -} - -func addPHPStopCommand(parent *cobra.Command) { - stopCmd := &cobra.Command{ - Use: "stop", - Short: i18n.T("cmd.php.stop.short"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPStop() - }, - } - - parent.AddCommand(stopCmd) -} - -func runPHPStop() error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.stop.stopping")) - - // We need to find running processes - // This is a simplified version - in practice you'd want to track PIDs - server := NewDevServer(Options{Dir: cwd}) - if err := server.Stop(); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.stop", "services"), err) - } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped")) - return nil -} - -func addPHPStatusCommand(parent *cobra.Command) { - statusCmd := &cobra.Command{ - Use: "status", - Short: i18n.T("cmd.php.status.short"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPStatus() - }, - } - - parent.AddCommand(statusCmd) -} - -func runPHPStatus() error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - if !IsLaravelProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_laravel_short")) - } - - appName := GetLaravelAppName(cwd) - if appName == "" { - appName = "Laravel" - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("project")), appName) - - // Detect available services - services := DetectServices(cwd) - cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.php.status.detected_services"))) - for _, svc := range services { - style := getServiceStyle(string(svc)) - cli.Print(" %s %s\n", style.Render("*"), svc) - } - cli.Blank() - - // Package manager - pm := DetectPackageManager(cwd) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.package_manager")), pm) - - // FrankenPHP status - if IsFrankenPHPProject(cwd) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.octane_server")), "FrankenPHP") - } - - // SSL status - appURL := GetLaravelAppURL(cwd) - if appURL != "" { - domain := ExtractDomainFromURL(appURL) - if CertsExist(domain, SSLOptions{}) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), successStyle.Render(i18n.T("cmd.php.status.ssl_installed"))) - } else { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), dimStyle.Render(i18n.T("cmd.php.status.ssl_not_setup"))) - } - } - - return nil -} - -var sslDomain string - -func addPHPSSLCommand(parent *cobra.Command) { - sslCmd := &cobra.Command{ - Use: "ssl", - Short: i18n.T("cmd.php.ssl.short"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPSSL(sslDomain) - }, - } - - sslCmd.Flags().StringVar(&sslDomain, "domain", "", i18n.T("cmd.php.ssl.flag.domain")) - - parent.AddCommand(sslCmd) -} - -func runPHPSSL(domain string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - // Get domain from APP_URL if not specified - if domain == "" { - appURL := GetLaravelAppURL(cwd) - if appURL != "" { - domain = ExtractDomainFromURL(appURL) - } - } - if domain == "" { - domain = "localhost" - } - - // Check if mkcert is installed - if !IsMkcertInstalled() { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.ssl.mkcert_not_installed")) - cli.Print("\n%s\n", i18n.T("common.hint.install_with")) - cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_macos")) - cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_linux")) - return errors.New(i18n.T("cmd.php.error.mkcert_not_installed")) - } - - cli.Print("%s %s\n", dimStyle.Render("SSL:"), i18n.T("cmd.php.ssl.setting_up", map[string]interface{}{"Domain": domain})) - - // Check if certs already exist - if CertsExist(domain, SSLOptions{}) { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.php.ssl.certs_exist")) - - certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) - return nil - } - - // Setup SSL - if err := SetupSSL(domain, SSLOptions{}); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.setup", "SSL"), err) - } - - certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.ssl.certs_created")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) - - return nil -} - -// Helper functions for dev commands - -func printServiceStatuses(statuses []ServiceStatus) { - for _, s := range statuses { - style := getServiceStyle(s.Name) - var statusText string - - if s.Error != nil { - statusText = phpStatusError.Render(i18n.T("cmd.php.status.error", map[string]interface{}{"Error": s.Error})) - } else if s.Running { - statusText = phpStatusRunning.Render(i18n.T("cmd.php.status.running")) - if s.Port > 0 { - statusText += dimStyle.Render(cli.Sprintf(" (%s)", i18n.T("cmd.php.status.port", map[string]interface{}{"Port": s.Port}))) - } - if s.PID > 0 { - statusText += dimStyle.Render(cli.Sprintf(" [%s]", i18n.T("cmd.php.status.pid", map[string]interface{}{"PID": s.PID}))) - } - } else { - statusText = phpStatusStopped.Render(i18n.T("cmd.php.status.stopped")) - } - - cli.Print(" %s %s\n", style.Render(s.Name+":"), statusText) - } -} - -func printColoredLog(line string) { - // Parse service prefix from log line - timestamp := time.Now().Format("15:04:05") - - var style *cli.AnsiStyle - serviceName := "" - - if strings.HasPrefix(line, "[FrankenPHP]") { - style = phpFrankenPHPStyle - serviceName = "FrankenPHP" - line = strings.TrimPrefix(line, "[FrankenPHP] ") - } else if strings.HasPrefix(line, "[Vite]") { - style = phpViteStyle - serviceName = "Vite" - line = strings.TrimPrefix(line, "[Vite] ") - } else if strings.HasPrefix(line, "[Horizon]") { - style = phpHorizonStyle - serviceName = "Horizon" - line = strings.TrimPrefix(line, "[Horizon] ") - } else if strings.HasPrefix(line, "[Reverb]") { - style = phpReverbStyle - serviceName = "Reverb" - line = strings.TrimPrefix(line, "[Reverb] ") - } else if strings.HasPrefix(line, "[Redis]") { - style = phpRedisStyle - serviceName = "Redis" - line = strings.TrimPrefix(line, "[Redis] ") - } else { - // Unknown service, print as-is - cli.Print("%s %s\n", dimStyle.Render(timestamp), line) - return - } - - cli.Print("%s %s %s\n", - dimStyle.Render(timestamp), - style.Render(cli.Sprintf("[%s]", serviceName)), - line, - ) -} - -func getServiceStyle(name string) *cli.AnsiStyle { - switch strings.ToLower(name) { - case "frankenphp": - return phpFrankenPHPStyle - case "vite": - return phpViteStyle - case "horizon": - return phpHorizonStyle - case "reverb": - return phpReverbStyle - case "redis": - return phpRedisStyle - default: - return dimStyle - } -} - -func containsService(services []DetectedService, target DetectedService) bool { - for _, s := range services { - if s == target { - return true - } - } - return false -} diff --git a/cmd_packages.go b/cmd_packages.go deleted file mode 100644 index fa1172b..0000000 --- a/cmd_packages.go +++ /dev/null @@ -1,146 +0,0 @@ -package php - -import ( - "os" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -func addPHPPackagesCommands(parent *cobra.Command) { - packagesCmd := &cobra.Command{ - Use: "packages", - Short: i18n.T("cmd.php.packages.short"), - Long: i18n.T("cmd.php.packages.long"), - } - parent.AddCommand(packagesCmd) - - addPHPPackagesLinkCommand(packagesCmd) - addPHPPackagesUnlinkCommand(packagesCmd) - addPHPPackagesUpdateCommand(packagesCmd) - addPHPPackagesListCommand(packagesCmd) -} - -func addPHPPackagesLinkCommand(parent *cobra.Command) { - linkCmd := &cobra.Command{ - Use: "link [paths...]", - Short: i18n.T("cmd.php.packages.link.short"), - Long: i18n.T("cmd.php.packages.link.long"), - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.link.linking")) - - if err := LinkPackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.link", "packages"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.link.done")) - return nil - }, - } - - parent.AddCommand(linkCmd) -} - -func addPHPPackagesUnlinkCommand(parent *cobra.Command) { - unlinkCmd := &cobra.Command{ - Use: "unlink [packages...]", - Short: i18n.T("cmd.php.packages.unlink.short"), - Long: i18n.T("cmd.php.packages.unlink.long"), - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.unlink.unlinking")) - - if err := UnlinkPackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.unlink", "packages"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.unlink.done")) - return nil - }, - } - - parent.AddCommand(unlinkCmd) -} - -func addPHPPackagesUpdateCommand(parent *cobra.Command) { - updateCmd := &cobra.Command{ - Use: "update [packages...]", - Short: i18n.T("cmd.php.packages.update.short"), - Long: i18n.T("cmd.php.packages.update.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.update.updating")) - - if err := UpdatePackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.update_packages"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.update.done")) - return nil - }, - } - - parent.AddCommand(updateCmd) -} - -func addPHPPackagesListCommand(parent *cobra.Command) { - listCmd := &cobra.Command{ - Use: "list", - Short: i18n.T("cmd.php.packages.list.short"), - Long: i18n.T("cmd.php.packages.list.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - packages, err := ListLinkedPackages(cwd) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.list", "packages"), err) - } - - if len(packages) == 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.none_found")) - return nil - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.linked")) - - for _, pkg := range packages { - name := pkg.Name - if name == "" { - name = i18n.T("cmd.php.packages.list.unknown") - } - version := pkg.Version - if version == "" { - version = "dev" - } - - cli.Print(" %s %s\n", successStyle.Render("*"), name) - cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), pkg.Path) - cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("version")), version) - cli.Blank() - } - - return nil - }, - } - - parent.AddCommand(listCmd) -} diff --git a/cmd_qa_runner.go b/cmd_qa_runner.go deleted file mode 100644 index 7e9d7ae..0000000 --- a/cmd_qa_runner.go +++ /dev/null @@ -1,343 +0,0 @@ -package php - -import ( - "context" - "path/filepath" - "strings" - "sync" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/framework" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/process" -) - -// QARunner orchestrates PHP QA checks using pkg/process. -type QARunner struct { - dir string - fix bool - service *process.Service - core *framework.Core - - // Output tracking - outputMu sync.Mutex - checkOutputs map[string][]string -} - -// NewQARunner creates a QA runner for the given directory. -func NewQARunner(dir string, fix bool) (*QARunner, error) { - // Create a Core with process service for the QA session - core, err := framework.New( - framework.WithName("process", process.NewService(process.Options{})), - ) - if err != nil { - return nil, cli.WrapVerb(err, "create", "process service") - } - - svc, err := framework.ServiceFor[*process.Service](core, "process") - if err != nil { - return nil, cli.WrapVerb(err, "get", "process service") - } - - runner := &QARunner{ - dir: dir, - fix: fix, - service: svc, - core: core, - checkOutputs: make(map[string][]string), - } - - return runner, nil -} - -// BuildSpecs creates RunSpecs for the given QA checks. -func (r *QARunner) BuildSpecs(checks []string) []process.RunSpec { - specs := make([]process.RunSpec, 0, len(checks)) - - for _, check := range checks { - spec := r.buildSpec(check) - if spec != nil { - specs = append(specs, *spec) - } - } - - return specs -} - -// buildSpec creates a RunSpec for a single check. -func (r *QARunner) buildSpec(check string) *process.RunSpec { - switch check { - case "audit": - return &process.RunSpec{ - Name: "audit", - Command: "composer", - Args: []string{"audit", "--format=summary"}, - Dir: r.dir, - } - - case "fmt": - m := getMedium() - formatter, found := DetectFormatter(r.dir) - if !found { - return nil - } - if formatter == FormatterPint { - vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint") - cmd := "pint" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{} - if !r.fix { - args = append(args, "--test") - } - return &process.RunSpec{ - Name: "fmt", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"audit"}, - } - } - return nil - - case "stan": - m := getMedium() - _, found := DetectAnalyser(r.dir) - if !found { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan") - cmd := "phpstan" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - return &process.RunSpec{ - Name: "stan", - Command: cmd, - Args: []string{"analyse", "--no-progress"}, - Dir: r.dir, - After: []string{"fmt"}, - } - - case "psalm": - m := getMedium() - _, found := DetectPsalm(r.dir) - if !found { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm") - cmd := "psalm" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{"--no-progress"} - if r.fix { - args = append(args, "--alter", "--issues=all") - } - return &process.RunSpec{ - Name: "psalm", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"stan"}, - } - - case "test": - m := getMedium() - // Check for Pest first, fall back to PHPUnit - pestBin := filepath.Join(r.dir, "vendor", "bin", "pest") - phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit") - - var cmd string - if m.IsFile(pestBin) { - cmd = pestBin - } else if m.IsFile(phpunitBin) { - cmd = phpunitBin - } else { - return nil - } - - // Tests depend on stan (or psalm if available) - after := []string{"stan"} - if _, found := DetectPsalm(r.dir); found { - after = []string{"psalm"} - } - - return &process.RunSpec{ - Name: "test", - Command: cmd, - Args: []string{}, - Dir: r.dir, - After: after, - } - - case "rector": - m := getMedium() - if !DetectRector(r.dir) { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector") - cmd := "rector" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{"process"} - if !r.fix { - args = append(args, "--dry-run") - } - return &process.RunSpec{ - Name: "rector", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"test"}, - AllowFailure: true, // Dry-run returns non-zero if changes would be made - } - - case "infection": - m := getMedium() - if !DetectInfection(r.dir) { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection") - cmd := "infection" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - return &process.RunSpec{ - Name: "infection", - Command: cmd, - Args: []string{"--min-msi=50", "--min-covered-msi=70", "--threads=4"}, - Dir: r.dir, - After: []string{"test"}, - AllowFailure: true, - } - } - - return nil -} - -// Run executes all QA checks and returns the results. -func (r *QARunner) Run(ctx context.Context, stages []QAStage) (*QARunResult, error) { - // Collect all checks from all stages - var allChecks []string - for _, stage := range stages { - checks := GetQAChecks(r.dir, stage) - allChecks = append(allChecks, checks...) - } - - if len(allChecks) == 0 { - return &QARunResult{Passed: true}, nil - } - - // Build specs - specs := r.BuildSpecs(allChecks) - if len(specs) == 0 { - return &QARunResult{Passed: true}, nil - } - - // Register output handler - r.core.RegisterAction(func(c *framework.Core, msg framework.Message) error { - switch m := msg.(type) { - case process.ActionProcessOutput: - r.outputMu.Lock() - // Extract check name from process ID mapping - for _, spec := range specs { - if strings.Contains(m.ID, spec.Name) || m.ID != "" { - // Store output for later display if needed - r.checkOutputs[spec.Name] = append(r.checkOutputs[spec.Name], m.Line) - break - } - } - r.outputMu.Unlock() - } - return nil - }) - - // Create runner and execute - runner := process.NewRunner(r.service) - result, err := runner.RunAll(ctx, specs) - if err != nil { - return nil, err - } - - // Convert to QA result - qaResult := &QARunResult{ - Passed: result.Success(), - Duration: result.Duration.String(), - Results: make([]QACheckRunResult, 0, len(result.Results)), - } - - for _, res := range result.Results { - qaResult.Results = append(qaResult.Results, QACheckRunResult{ - Name: res.Name, - Passed: res.Passed(), - Skipped: res.Skipped, - ExitCode: res.ExitCode, - Duration: res.Duration.String(), - Output: res.Output, - }) - if res.Passed() { - qaResult.PassedCount++ - } else if res.Skipped { - qaResult.SkippedCount++ - } else { - qaResult.FailedCount++ - } - } - - return qaResult, nil -} - -// GetCheckOutput returns captured output for a check. -func (r *QARunner) GetCheckOutput(check string) []string { - r.outputMu.Lock() - defer r.outputMu.Unlock() - return r.checkOutputs[check] -} - -// QARunResult holds the results of running QA checks. -type QARunResult struct { - Passed bool `json:"passed"` - Duration string `json:"duration"` - Results []QACheckRunResult `json:"results"` - PassedCount int `json:"passed_count"` - FailedCount int `json:"failed_count"` - SkippedCount int `json:"skipped_count"` -} - -// QACheckRunResult holds the result of a single QA check. -type QACheckRunResult struct { - Name string `json:"name"` - Passed bool `json:"passed"` - Skipped bool `json:"skipped"` - ExitCode int `json:"exit_code"` - Duration string `json:"duration"` - Output string `json:"output,omitempty"` -} - -// GetIssueMessage returns an issue message for a check. -func (r QACheckRunResult) GetIssueMessage() string { - if r.Passed || r.Skipped { - return "" - } - switch r.Name { - case "audit": - return i18n.T("i18n.done.find", "vulnerabilities") - case "fmt": - return i18n.T("i18n.done.find", "style issues") - case "stan": - return i18n.T("i18n.done.find", "analysis errors") - case "psalm": - return i18n.T("i18n.done.find", "type errors") - case "test": - return i18n.T("i18n.done.fail", "tests") - case "rector": - return i18n.T("i18n.done.find", "refactoring suggestions") - case "infection": - return i18n.T("i18n.fail.pass", "mutation testing") - default: - return i18n.T("i18n.done.find", "issues") - } -} diff --git a/cmd_quality.go b/cmd_quality.go deleted file mode 100644 index e76363e..0000000 --- a/cmd_quality.go +++ /dev/null @@ -1,815 +0,0 @@ -package php - -import ( - "context" - "encoding/json" - "errors" - "os" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -var ( - testParallel bool - testCoverage bool - testFilter string - testGroup string - testJSON bool -) - -func addPHPTestCommand(parent *cobra.Command) { - testCmd := &cobra.Command{ - Use: "test", - Short: i18n.T("cmd.php.test.short"), - Long: i18n.T("cmd.php.test.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - if !testJSON { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "tests")) - } - - ctx := context.Background() - - opts := TestOptions{ - Dir: cwd, - Filter: testFilter, - Parallel: testParallel, - Coverage: testCoverage, - JUnit: testJSON, - Output: os.Stdout, - } - - if testGroup != "" { - opts.Groups = []string{testGroup} - } - - if err := RunTests(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.run", "tests"), err) - } - - return nil - }, - } - - testCmd.Flags().BoolVar(&testParallel, "parallel", false, i18n.T("cmd.php.test.flag.parallel")) - testCmd.Flags().BoolVar(&testCoverage, "coverage", false, i18n.T("cmd.php.test.flag.coverage")) - testCmd.Flags().StringVar(&testFilter, "filter", "", i18n.T("cmd.php.test.flag.filter")) - testCmd.Flags().StringVar(&testGroup, "group", "", i18n.T("cmd.php.test.flag.group")) - testCmd.Flags().BoolVar(&testJSON, "junit", false, i18n.T("cmd.php.test.flag.junit")) - - parent.AddCommand(testCmd) -} - -var ( - fmtFix bool - fmtDiff bool - fmtJSON bool -) - -func addPHPFmtCommand(parent *cobra.Command) { - fmtCmd := &cobra.Command{ - Use: "fmt [paths...]", - Short: i18n.T("cmd.php.fmt.short"), - Long: i18n.T("cmd.php.fmt.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Detect formatter - formatter, found := DetectFormatter(cwd) - if !found { - return errors.New(i18n.T("cmd.php.fmt.no_formatter")) - } - - if !fmtJSON { - var msg string - if fmtFix { - msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter}) - } else { - msg = i18n.ProgressSubject("check", "code style") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg) - } - - ctx := context.Background() - - opts := FormatOptions{ - Dir: cwd, - Fix: fmtFix, - Diff: fmtDiff, - JSON: fmtJSON, - Output: os.Stdout, - } - - // Get any additional paths from args - if len(args) > 0 { - opts.Paths = args - } - - if err := Format(ctx, opts); err != nil { - if fmtFix { - return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_failed"), err) - } - return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_issues"), err) - } - - if !fmtJSON { - if fmtFix { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code formatted"})) - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.fmt.no_issues")) - } - } - - return nil - }, - } - - fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("cmd.php.fmt.flag.fix")) - fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff")) - fmtCmd.Flags().BoolVar(&fmtJSON, "json", false, i18n.T("common.flag.json")) - - parent.AddCommand(fmtCmd) -} - -var ( - stanLevel int - stanMemory string - stanJSON bool - stanSARIF bool -) - -func addPHPStanCommand(parent *cobra.Command) { - stanCmd := &cobra.Command{ - Use: "stan [paths...]", - Short: i18n.T("cmd.php.analyse.short"), - Long: i18n.T("cmd.php.analyse.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Detect analyser - _, found := DetectAnalyser(cwd) - if !found { - return errors.New(i18n.T("cmd.php.analyse.no_analyser")) - } - - if stanJSON && stanSARIF { - return errors.New(i18n.T("common.error.json_sarif_exclusive")) - } - - if !stanJSON && !stanSARIF { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "static analysis")) - } - - ctx := context.Background() - - opts := AnalyseOptions{ - Dir: cwd, - Level: stanLevel, - Memory: stanMemory, - JSON: stanJSON, - SARIF: stanSARIF, - Output: os.Stdout, - } - - // Get any additional paths from args - if len(args) > 0 { - opts.Paths = args - } - - if err := Analyse(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.analysis_issues"), err) - } - - if !stanJSON && !stanSARIF { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) - } - return nil - }, - } - - stanCmd.Flags().IntVar(&stanLevel, "level", 0, i18n.T("cmd.php.analyse.flag.level")) - stanCmd.Flags().StringVar(&stanMemory, "memory", "", i18n.T("cmd.php.analyse.flag.memory")) - stanCmd.Flags().BoolVar(&stanJSON, "json", false, i18n.T("common.flag.json")) - stanCmd.Flags().BoolVar(&stanSARIF, "sarif", false, i18n.T("common.flag.sarif")) - - parent.AddCommand(stanCmd) -} - -// ============================================================================= -// New QA Commands -// ============================================================================= - -var ( - psalmLevel int - psalmFix bool - psalmBaseline bool - psalmShowInfo bool - psalmJSON bool - psalmSARIF bool -) - -func addPHPPsalmCommand(parent *cobra.Command) { - psalmCmd := &cobra.Command{ - Use: "psalm", - Short: i18n.T("cmd.php.psalm.short"), - Long: i18n.T("cmd.php.psalm.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Psalm is available - _, found := DetectPsalm(cwd) - if !found { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.psalm.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.psalm.install")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.psalm.setup")) - return errors.New(i18n.T("cmd.php.error.psalm_not_installed")) - } - - if psalmJSON && psalmSARIF { - return errors.New(i18n.T("common.error.json_sarif_exclusive")) - } - - if !psalmJSON && !psalmSARIF { - var msg string - if psalmFix { - msg = i18n.T("cmd.php.psalm.analysing_fixing") - } else { - msg = i18n.T("cmd.php.psalm.analysing") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.psalm")), msg) - } - - ctx := context.Background() - - opts := PsalmOptions{ - Dir: cwd, - Level: psalmLevel, - Fix: psalmFix, - Baseline: psalmBaseline, - ShowInfo: psalmShowInfo, - JSON: psalmJSON, - SARIF: psalmSARIF, - Output: os.Stdout, - } - - if err := RunPsalm(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.psalm_issues"), err) - } - - if !psalmJSON && !psalmSARIF { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) - } - return nil - }, - } - - psalmCmd.Flags().IntVar(&psalmLevel, "level", 0, i18n.T("cmd.php.psalm.flag.level")) - psalmCmd.Flags().BoolVar(&psalmFix, "fix", false, i18n.T("common.flag.fix")) - psalmCmd.Flags().BoolVar(&psalmBaseline, "baseline", false, i18n.T("cmd.php.psalm.flag.baseline")) - psalmCmd.Flags().BoolVar(&psalmShowInfo, "show-info", false, i18n.T("cmd.php.psalm.flag.show_info")) - psalmCmd.Flags().BoolVar(&psalmJSON, "json", false, i18n.T("common.flag.json")) - psalmCmd.Flags().BoolVar(&psalmSARIF, "sarif", false, i18n.T("common.flag.sarif")) - - parent.AddCommand(psalmCmd) -} - -var ( - auditJSONOutput bool - auditFix bool -) - -func addPHPAuditCommand(parent *cobra.Command) { - auditCmd := &cobra.Command{ - Use: "audit", - Short: i18n.T("cmd.php.audit.short"), - Long: i18n.T("cmd.php.audit.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.audit")), i18n.T("cmd.php.audit.scanning")) - - ctx := context.Background() - - results, err := RunAudit(ctx, AuditOptions{ - Dir: cwd, - JSON: auditJSONOutput, - Fix: auditFix, - Output: os.Stdout, - }) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.audit_failed"), err) - } - - // Print results - totalVulns := 0 - hasErrors := false - - for _, result := range results { - icon := successStyle.Render("✓") - status := successStyle.Render(i18n.T("cmd.php.audit.secure")) - - if result.Error != nil { - icon = errorStyle.Render("✗") - status = errorStyle.Render(i18n.T("cmd.php.audit.error")) - hasErrors = true - } else if result.Vulnerabilities > 0 { - icon = errorStyle.Render("✗") - status = errorStyle.Render(i18n.T("cmd.php.audit.vulnerabilities", map[string]interface{}{"Count": result.Vulnerabilities})) - totalVulns += result.Vulnerabilities - } - - cli.Print(" %s %s %s\n", icon, dimStyle.Render(result.Tool+":"), status) - - // Show advisories - for _, adv := range result.Advisories { - severity := adv.Severity - if severity == "" { - severity = "unknown" - } - sevStyle := getSeverityStyle(severity) - cli.Print(" %s %s\n", sevStyle.Render("["+severity+"]"), adv.Package) - if adv.Title != "" { - cli.Print(" %s\n", dimStyle.Render(adv.Title)) - } - } - } - - cli.Blank() - - if totalVulns > 0 { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.audit.found_vulns", map[string]interface{}{"Count": totalVulns})) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fix")), i18n.T("common.hint.fix_deps")) - return errors.New(i18n.T("cmd.php.error.vulns_found")) - } - - if hasErrors { - return errors.New(i18n.T("cmd.php.audit.completed_errors")) - } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.audit.all_secure")) - return nil - }, - } - - auditCmd.Flags().BoolVar(&auditJSONOutput, "json", false, i18n.T("common.flag.json")) - auditCmd.Flags().BoolVar(&auditFix, "fix", false, i18n.T("cmd.php.audit.flag.fix")) - - parent.AddCommand(auditCmd) -} - -var ( - securitySeverity string - securityJSONOutput bool - securitySarif bool - securityURL string -) - -func addPHPSecurityCommand(parent *cobra.Command) { - securityCmd := &cobra.Command{ - Use: "security", - Short: i18n.T("cmd.php.security.short"), - Long: i18n.T("cmd.php.security.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.security")), i18n.ProgressSubject("run", "security checks")) - - ctx := context.Background() - - result, err := RunSecurityChecks(ctx, SecurityOptions{ - Dir: cwd, - Severity: securitySeverity, - JSON: securityJSONOutput, - SARIF: securitySarif, - URL: securityURL, - Output: os.Stdout, - }) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.security_failed"), err) - } - - // Print results by category - currentCategory := "" - for _, check := range result.Checks { - category := strings.Split(check.ID, "_")[0] - if category != currentCategory { - if currentCategory != "" { - cli.Blank() - } - currentCategory = category - cli.Print(" %s\n", dimStyle.Render(strings.ToUpper(category)+i18n.T("cmd.php.security.checks_suffix"))) - } - - icon := successStyle.Render("✓") - if !check.Passed { - icon = getSeverityStyle(check.Severity).Render("✗") - } - - cli.Print(" %s %s\n", icon, check.Name) - if !check.Passed && check.Message != "" { - cli.Print(" %s\n", dimStyle.Render(check.Message)) - if check.Fix != "" { - cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("fix")), check.Fix) - } - } - } - - cli.Blank() - - // Print summary - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.php.security.summary")) - cli.Print(" %s %d/%d\n", dimStyle.Render(i18n.T("cmd.php.security.passed")), result.Summary.Passed, result.Summary.Total) - - if result.Summary.Critical > 0 { - cli.Print(" %s %d\n", phpSecurityCriticalStyle.Render(i18n.T("cmd.php.security.critical")), result.Summary.Critical) - } - if result.Summary.High > 0 { - cli.Print(" %s %d\n", phpSecurityHighStyle.Render(i18n.T("cmd.php.security.high")), result.Summary.High) - } - if result.Summary.Medium > 0 { - cli.Print(" %s %d\n", phpSecurityMediumStyle.Render(i18n.T("cmd.php.security.medium")), result.Summary.Medium) - } - if result.Summary.Low > 0 { - cli.Print(" %s %d\n", phpSecurityLowStyle.Render(i18n.T("cmd.php.security.low")), result.Summary.Low) - } - - if result.Summary.Critical > 0 || result.Summary.High > 0 { - return errors.New(i18n.T("cmd.php.error.critical_high_issues")) - } - - return nil - }, - } - - securityCmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.php.security.flag.severity")) - securityCmd.Flags().BoolVar(&securityJSONOutput, "json", false, i18n.T("common.flag.json")) - securityCmd.Flags().BoolVar(&securitySarif, "sarif", false, i18n.T("cmd.php.security.flag.sarif")) - securityCmd.Flags().StringVar(&securityURL, "url", "", i18n.T("cmd.php.security.flag.url")) - - parent.AddCommand(securityCmd) -} - -var ( - qaQuick bool - qaFull bool - qaFix bool - qaJSON bool -) - -func addPHPQACommand(parent *cobra.Command) { - qaCmd := &cobra.Command{ - Use: "qa", - Short: i18n.T("cmd.php.qa.short"), - Long: i18n.T("cmd.php.qa.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Determine stages - opts := QAOptions{ - Dir: cwd, - Quick: qaQuick, - Full: qaFull, - Fix: qaFix, - JSON: qaJSON, - } - stages := GetQAStages(opts) - - // Print header - if !qaJSON { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "QA pipeline")) - } - - ctx := context.Background() - - // Create QA runner using pkg/process - runner, err := NewQARunner(cwd, qaFix) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.create", "QA runner"), err) - } - - // Run all checks with dependency ordering - result, err := runner.Run(ctx, stages) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err) - } - - // Display results by stage (skip when JSON output is enabled) - if !qaJSON { - currentStage := "" - for _, checkResult := range result.Results { - // Determine stage for this check - stage := getCheckStage(checkResult.Name, stages, cwd) - if stage != currentStage { - if currentStage != "" { - cli.Blank() - } - currentStage = stage - cli.Print("%s\n", phpQAStageStyle.Render("── "+strings.ToUpper(stage)+" ──")) - } - - icon := phpQAPassedStyle.Render("✓") - status := phpQAPassedStyle.Render(i18n.T("i18n.done.pass")) - if checkResult.Skipped { - icon = dimStyle.Render("-") - status = dimStyle.Render(i18n.T("i18n.done.skip")) - } else if !checkResult.Passed { - icon = phpQAFailedStyle.Render("✗") - status = phpQAFailedStyle.Render(i18n.T("i18n.done.fail")) - } - - cli.Print(" %s %s %s %s\n", icon, checkResult.Name, status, dimStyle.Render(checkResult.Duration)) - } - cli.Blank() - - // Print summary - if result.Passed { - cli.Print("%s %s\n", phpQAPassedStyle.Render("QA PASSED:"), i18n.T("i18n.count.check", result.PassedCount)+" "+i18n.T("i18n.done.pass")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("i18n.label.duration")), result.Duration) - return nil - } - - cli.Print("%s %s\n\n", phpQAFailedStyle.Render("QA FAILED:"), i18n.T("i18n.count.check", result.PassedCount)+"/"+cli.Sprint(len(result.Results))+" "+i18n.T("i18n.done.pass")) - - // Show what needs fixing - cli.Print("%s\n", dimStyle.Render(i18n.T("i18n.label.fix"))) - for _, checkResult := range result.Results { - if checkResult.Passed || checkResult.Skipped { - continue - } - fixCmd := getQAFixCommand(checkResult.Name, qaFix) - issue := checkResult.GetIssueMessage() - if issue == "" { - issue = "issues found" - } - cli.Print(" %s %s\n", phpQAFailedStyle.Render("*"), checkResult.Name+": "+issue) - if fixCmd != "" { - cli.Print(" %s %s\n", dimStyle.Render("->"), fixCmd) - } - } - - return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline")) - } - - // JSON mode: output results as JSON - output, err := json.MarshalIndent(result, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - - if !result.Passed { - return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline")) - } - return nil - }, - } - - qaCmd.Flags().BoolVar(&qaQuick, "quick", false, i18n.T("cmd.php.qa.flag.quick")) - qaCmd.Flags().BoolVar(&qaFull, "full", false, i18n.T("cmd.php.qa.flag.full")) - qaCmd.Flags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix")) - qaCmd.Flags().BoolVar(&qaJSON, "json", false, i18n.T("common.flag.json")) - - parent.AddCommand(qaCmd) -} - -// getCheckStage determines which stage a check belongs to. -func getCheckStage(checkName string, stages []QAStage, dir string) string { - for _, stage := range stages { - checks := GetQAChecks(dir, stage) - for _, c := range checks { - if c == checkName { - return string(stage) - } - } - } - return "unknown" -} - -func getQAFixCommand(checkName string, fixEnabled bool) string { - switch checkName { - case "audit": - return i18n.T("i18n.progress.update", "dependencies") - case "fmt": - if fixEnabled { - return "" - } - return "core php fmt --fix" - case "stan": - return i18n.T("i18n.progress.fix", "PHPStan errors") - case "psalm": - return i18n.T("i18n.progress.fix", "Psalm errors") - case "test": - return i18n.T("i18n.progress.fix", i18n.T("i18n.done.fail")+" tests") - case "rector": - if fixEnabled { - return "" - } - return "core php rector --fix" - case "infection": - return i18n.T("i18n.progress.improve", "test coverage") - } - return "" -} - -var ( - rectorFix bool - rectorDiff bool - rectorClearCache bool -) - -func addPHPRectorCommand(parent *cobra.Command) { - rectorCmd := &cobra.Command{ - Use: "rector", - Short: i18n.T("cmd.php.rector.short"), - Long: i18n.T("cmd.php.rector.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Rector is available - if !DetectRector(cwd) { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.rector.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.rector.install")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.rector.setup")) - return errors.New(i18n.T("cmd.php.error.rector_not_installed")) - } - - var msg string - if rectorFix { - msg = i18n.T("cmd.php.rector.refactoring") - } else { - msg = i18n.T("cmd.php.rector.analysing") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.rector")), msg) - - ctx := context.Background() - - opts := RectorOptions{ - Dir: cwd, - Fix: rectorFix, - Diff: rectorDiff, - ClearCache: rectorClearCache, - Output: os.Stdout, - } - - if err := RunRector(ctx, opts); err != nil { - if rectorFix { - return cli.Err("%s: %w", i18n.T("cmd.php.error.rector_failed"), err) - } - // Dry-run returns non-zero if changes would be made - cli.Print("\n%s %s\n", phpQAWarningStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.rector.changes_suggested")) - return nil - } - - if rectorFix { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code refactored"})) - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.rector.no_changes")) - } - return nil - }, - } - - rectorCmd.Flags().BoolVar(&rectorFix, "fix", false, i18n.T("cmd.php.rector.flag.fix")) - rectorCmd.Flags().BoolVar(&rectorDiff, "diff", false, i18n.T("cmd.php.rector.flag.diff")) - rectorCmd.Flags().BoolVar(&rectorClearCache, "clear-cache", false, i18n.T("cmd.php.rector.flag.clear_cache")) - - parent.AddCommand(rectorCmd) -} - -var ( - infectionMinMSI int - infectionMinCoveredMSI int - infectionThreads int - infectionFilter string - infectionOnlyCovered bool -) - -func addPHPInfectionCommand(parent *cobra.Command) { - infectionCmd := &cobra.Command{ - Use: "infection", - Short: i18n.T("cmd.php.infection.short"), - Long: i18n.T("cmd.php.infection.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Infection is available - if !DetectInfection(cwd) { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.infection.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.infection.install")) - return errors.New(i18n.T("cmd.php.error.infection_not_installed")) - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.infection")), i18n.ProgressSubject("run", "mutation testing")) - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.infection.note")) - - ctx := context.Background() - - opts := InfectionOptions{ - Dir: cwd, - MinMSI: infectionMinMSI, - MinCoveredMSI: infectionMinCoveredMSI, - Threads: infectionThreads, - Filter: infectionFilter, - OnlyCovered: infectionOnlyCovered, - Output: os.Stdout, - } - - if err := RunInfection(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.infection_failed"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.infection.complete")) - return nil - }, - } - - infectionCmd.Flags().IntVar(&infectionMinMSI, "min-msi", 0, i18n.T("cmd.php.infection.flag.min_msi")) - infectionCmd.Flags().IntVar(&infectionMinCoveredMSI, "min-covered-msi", 0, i18n.T("cmd.php.infection.flag.min_covered_msi")) - infectionCmd.Flags().IntVar(&infectionThreads, "threads", 0, i18n.T("cmd.php.infection.flag.threads")) - infectionCmd.Flags().StringVar(&infectionFilter, "filter", "", i18n.T("cmd.php.infection.flag.filter")) - infectionCmd.Flags().BoolVar(&infectionOnlyCovered, "only-covered", false, i18n.T("cmd.php.infection.flag.only_covered")) - - parent.AddCommand(infectionCmd) -} - -func getSeverityStyle(severity string) *cli.AnsiStyle { - switch strings.ToLower(severity) { - case "critical": - return phpSecurityCriticalStyle - case "high": - return phpSecurityHighStyle - case "medium": - return phpSecurityMediumStyle - case "low": - return phpSecurityLowStyle - default: - return dimStyle - } -} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..eea4d2e --- /dev/null +++ b/composer.json @@ -0,0 +1,92 @@ +{ + "name": "core/php", + "description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading", + "keywords": [ + "laravel", + "modular", + "monolith", + "framework", + "events", + "modules" + ], + "license": "EUPL-1.2", + "authors": [ + { + "name": "Host UK", + "email": "support@host.uk.com" + } + ], + "require": { + "php": "^8.2", + "laravel/framework": "^11.0|^12.0", + "laravel/pennant": "^1.0", + "livewire/livewire": "^3.0|^4.0" + }, + "require-dev": { + "fakerphp/faker": "^1.23", + "infection/infection": "^0.32.3", + "larastan/larastan": "^3.9", + "laravel/pint": "^1.18", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.6", + "orchestra/testbench": "^9.0|^10.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpunit/phpunit": "^11.5", + "psalm/plugin-laravel": "^3.0", + "rector/rector": "^2.3", + "roave/security-advisories": "dev-latest", + "spatie/laravel-activitylog": "^4.8", + "vimeo/psalm": "^6.14" + }, + "suggest": { + "spatie/laravel-activitylog": "Required for activity logging features (^4.0)" + }, + "autoload": { + "psr-4": { + "Core\\": "src/Core/", + "Core\\Website\\": "src/Website/", + "Core\\Mod\\": "src/Mod/", + "Core\\Plug\\": "src/Plug/" + }, + "files": [ + "src/Core/Media/Thumbnail/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Core\\Tests\\": "tests/", + "Core\\TestCore\\": "tests/Fixtures/Core/TestCore/", + "App\\Custom\\": "tests/Fixtures/Custom/", + "Mod\\": "tests/Fixtures/Mod/", + "Plug\\": "tests/Fixtures/Plug/", + "Website\\": "tests/Fixtures/Website/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "pint": "vendor/bin/pint" + }, + "extra": { + "laravel": { + "providers": [ + "Core\\LifecycleEventProvider", + "Core\\Lang\\LangServiceProvider", + "Core\\Bouncer\\Gate\\Boot" + ] + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true, + "phpstan/extension-installer": true, + "infection/extension-installer": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/core.php b/config/core.php new file mode 100644 index 0000000..bf5f195 --- /dev/null +++ b/config/core.php @@ -0,0 +1,455 @@ + [ + 'name' => env('APP_NAME', 'Core PHP'), + 'description' => env('APP_DESCRIPTION', 'A modular monolith framework'), + 'tagline' => env('APP_TAGLINE', 'Build powerful applications with a clean, modular architecture.'), + 'cta_text' => env('APP_CTA_TEXT', 'Join developers building with our framework.'), + 'icon' => env('APP_ICON', 'cube'), + 'color' => env('APP_COLOR', 'violet'), + 'logo' => env('APP_LOGO'), // Path relative to public/, e.g. 'images/logo.svg' + 'privacy_url' => env('APP_PRIVACY_URL'), + 'terms_url' => env('APP_TERMS_URL'), + 'powered_by' => env('APP_POWERED_BY'), + 'powered_by_url' => env('APP_POWERED_BY_URL'), + ], + + /* + |-------------------------------------------------------------------------- + | Module Paths + |-------------------------------------------------------------------------- + | + | Directories to scan for module Boot.php files with $listens declarations. + | Each path should be an absolute path to a directory containing modules. + | + | Example: + | 'module_paths' => [ + | app_path('Core'), + | app_path('Mod'), + | ], + | + */ + + 'module_paths' => [ + // app_path('Core'), + // app_path('Mod'), + ], + + /* + |-------------------------------------------------------------------------- + | FontAwesome Configuration + |-------------------------------------------------------------------------- + | + | Configure FontAwesome Pro detection and fallback behaviour. + | + */ + + 'fontawesome' => [ + // Set to true if you have a FontAwesome Pro licence + 'pro' => env('FONTAWESOME_PRO', false), + + // Your FontAwesome Kit ID (optional) + 'kit' => env('FONTAWESOME_KIT'), + ], + + /* + |-------------------------------------------------------------------------- + | Pro Fallback Behaviour + |-------------------------------------------------------------------------- + | + | How to handle Pro-only components when Pro packages aren't installed. + | + | Options: + | - 'error': Throw exception in dev, silent in production + | - 'fallback': Use free alternatives where possible + | - 'silent': Render nothing for Pro-only components + | + */ + + 'pro_fallback' => env('CORE_PRO_FALLBACK', 'error'), + + /* + |-------------------------------------------------------------------------- + | Icon Defaults + |-------------------------------------------------------------------------- + | + | Default icon style when not specified. Only applies when not using + | auto-detection (brand/jelly lists). + | + */ + + 'icon' => [ + 'default_style' => 'solid', + ], + + /* + |-------------------------------------------------------------------------- + | Search Configuration + |-------------------------------------------------------------------------- + | + | Configure the unified search feature including searchable API endpoints. + | Add your application's API endpoints here to include them in search results. + | + */ + + 'search' => [ + 'api_endpoints' => [ + // Example endpoints - override in your application's config + // ['method' => 'GET', 'path' => '/api/v1/users', 'description' => 'List users'], + // ['method' => 'POST', 'path' => '/api/v1/users', 'description' => 'Create user'], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Email Shield Configuration + |-------------------------------------------------------------------------- + | + | Configure the Email Shield validation and statistics module. + | Statistics track daily email validation counts for monitoring and + | analysis. Old records are automatically pruned based on retention period. + | + | Schedule the prune command in your app/Console/Kernel.php: + | $schedule->command('email-shield:prune')->daily(); + | + */ + + 'email_shield' => [ + // Number of days to retain email shield statistics records. + // Records older than this will be deleted by the prune command. + // Set to 0 to disable automatic pruning. + 'retention_days' => env('CORE_EMAIL_SHIELD_RETENTION_DAYS', 90), + ], + + /* + |-------------------------------------------------------------------------- + | Admin Menu Configuration + |-------------------------------------------------------------------------- + | + | Configure the admin menu caching behaviour. Menu items are cached per + | user/workspace combination to improve performance on repeated requests. + | + */ + + 'admin_menu' => [ + // Whether to enable caching for static menu items. + // Set to false during development for instant menu updates. + 'cache_enabled' => env('CORE_ADMIN_MENU_CACHE', true), + + // Cache TTL in seconds (default: 5 minutes). + // Lower values mean more frequent cache misses but fresher menus. + 'cache_ttl' => env('CORE_ADMIN_MENU_CACHE_TTL', 300), + ], + + /* + |-------------------------------------------------------------------------- + | Storage Resilience Configuration + |-------------------------------------------------------------------------- + | + | Configure how the application handles Redis failures. When Redis becomes + | unavailable, the system can either silently fall back to database storage + | or throw an exception. + | + */ + + 'storage' => [ + // Whether to silently fall back to database when Redis fails. + // Set to false to throw exceptions on Redis failure. + 'silent_fallback' => env('CORE_STORAGE_SILENT_FALLBACK', true), + + // Log level for fallback events: 'debug', 'info', 'notice', 'warning', 'error', 'critical' + 'fallback_log_level' => env('CORE_STORAGE_FALLBACK_LOG_LEVEL', 'warning'), + + // Whether to dispatch RedisFallbackActivated events for monitoring/alerting + 'dispatch_fallback_events' => env('CORE_STORAGE_DISPATCH_EVENTS', true), + + /* + |---------------------------------------------------------------------- + | Circuit Breaker Configuration + |---------------------------------------------------------------------- + | + | The circuit breaker prevents cascading failures when Redis becomes + | unavailable. When failures exceed the threshold, the circuit opens + | and requests go directly to the fallback, avoiding repeated + | connection attempts that slow down the application. + | + */ + + 'circuit_breaker' => [ + // Enable/disable the circuit breaker + 'enabled' => env('CORE_STORAGE_CIRCUIT_BREAKER_ENABLED', true), + + // Number of failures before opening the circuit + 'failure_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_FAILURES', 5), + + // Seconds to wait before attempting recovery (half-open state) + 'recovery_timeout' => env('CORE_STORAGE_CIRCUIT_BREAKER_RECOVERY', 30), + + // Number of successful operations to close the circuit + 'success_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_SUCCESSES', 2), + + // Cache driver for storing circuit breaker state (use non-Redis driver) + 'state_driver' => env('CORE_STORAGE_CIRCUIT_BREAKER_DRIVER', 'file'), + ], + + /* + |---------------------------------------------------------------------- + | Storage Metrics Configuration + |---------------------------------------------------------------------- + | + | Storage metrics collect information about cache operations including + | hit/miss rates, latencies, and fallback activations. Use these + | metrics for monitoring cache health and performance tuning. + | + */ + + 'metrics' => [ + // Enable/disable metrics collection + 'enabled' => env('CORE_STORAGE_METRICS_ENABLED', true), + + // Maximum latency samples to keep per driver (for percentile calculations) + 'max_samples' => env('CORE_STORAGE_METRICS_MAX_SAMPLES', 1000), + + // Whether to log metrics events + 'log_enabled' => env('CORE_STORAGE_METRICS_LOG', true), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Service Configuration + |-------------------------------------------------------------------------- + | + | Configure service discovery and dependency resolution. Services are + | discovered by scanning module paths for classes implementing + | ServiceDefinition. + | + */ + + 'services' => [ + // Whether to cache service discovery results + 'cache_discovery' => env('CORE_SERVICES_CACHE_DISCOVERY', true), + ], + + /* + |-------------------------------------------------------------------------- + | Language & Translation Configuration + |-------------------------------------------------------------------------- + | + | Configure translation fallback chains and missing key validation. + | The fallback chain allows regional locales to fall back to their base + | locale before using the application's fallback locale. + | + | Example chain: en_GB -> en -> fallback_locale (from config/app.php) + | + */ + + 'lang' => [ + // Enable locale chain fallback (e.g., en_GB -> en -> fallback) + // When true, regional locales like 'en_GB' will first try 'en' before + // falling back to the application's fallback_locale. + 'fallback_chain' => env('CORE_LANG_FALLBACK_CHAIN', true), + + // Warn about missing translation keys in development environments. + // Set to true to always enable, false to always disable, or leave + // null to auto-enable in local/development/testing environments. + 'validate_keys' => env('CORE_LANG_VALIDATE_KEYS'), + + // Log missing translation keys when validation is enabled. + 'log_missing_keys' => env('CORE_LANG_LOG_MISSING_KEYS', true), + + // Log level for missing translation key warnings. + // Options: 'debug', 'info', 'notice', 'warning', 'error', 'critical' + 'missing_key_log_level' => env('CORE_LANG_MISSING_KEY_LOG_LEVEL', 'debug'), + + // Enable ICU message format support. + // Requires the PHP intl extension for full functionality. + // When disabled, ICU patterns will use basic placeholder replacement. + 'icu_enabled' => env('CORE_LANG_ICU_ENABLED', true), + ], + + /* + |-------------------------------------------------------------------------- + | Bouncer Action Gate Configuration + |-------------------------------------------------------------------------- + | + | Configure the action whitelisting system. Philosophy: "If it wasn't + | trained, it doesn't exist." Every controller action must be explicitly + | permitted. Unknown actions are blocked (production) or prompt for + | approval (training mode). + | + */ + + 'bouncer' => [ + // Enable training mode to allow approving new actions interactively. + // In production, this should be false to enforce strict whitelisting. + // In development/staging, enable to train the system with valid actions. + 'training_mode' => env('CORE_BOUNCER_TRAINING_MODE', false), + + // Whether to enable the action gate middleware. + // Set to false to completely disable action whitelisting. + 'enabled' => env('CORE_BOUNCER_ENABLED', true), + + // Guards that should have action gating applied. + // Actions on routes using these middleware groups will be checked. + 'guarded_middleware' => ['web', 'admin', 'api', 'client'], + + // Routes matching these patterns will bypass the action gate. + // Use for login pages, public assets, health checks, etc. + 'bypass_patterns' => [ + 'login', + 'logout', + 'register', + 'password/*', + 'sanctum/*', + 'livewire/*', + '_debugbar/*', + 'horizon/*', + 'telescope/*', + ], + + // Number of days to retain action request logs. + // Set to 0 to disable automatic pruning. + 'log_retention_days' => env('CORE_BOUNCER_LOG_RETENTION', 30), + + // Whether to log allowed requests (can generate many records). + // Recommended: false in production, true during training. + 'log_allowed_requests' => env('CORE_BOUNCER_LOG_ALLOWED', false), + + /* + |---------------------------------------------------------------------- + | Honeypot Configuration + |---------------------------------------------------------------------- + | + | Configure the honeypot system that traps bots ignoring robots.txt. + | Paths listed in robots.txt as disallowed are monitored; any request + | indicates a bot that doesn't respect robots.txt. + | + */ + + 'honeypot' => [ + // Whether to auto-block IPs that hit critical honeypot paths. + // When enabled, IPs hitting paths like /admin or /.env are blocked. + // Set to false to require manual review of all honeypot hits. + 'auto_block_critical' => env('CORE_BOUNCER_HONEYPOT_AUTO_BLOCK', true), + + // Rate limiting for honeypot logging to prevent DoS via log flooding. + // Maximum number of log entries per IP within the time window. + 'rate_limit_max' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_MAX', 10), + + // Rate limit time window in seconds (default: 60 = 1 minute). + 'rate_limit_window' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_WINDOW', 60), + + // Severity levels for honeypot paths. + // 'critical' - Active probing (admin panels, config files). + // 'warning' - General robots.txt violation. + 'severity_levels' => [ + 'critical' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_CRITICAL', 'critical'), + 'warning' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_WARNING', 'warning'), + ], + + // Paths that indicate critical/malicious probing. + // Requests to these paths result in 'critical' severity. + // Supports prefix matching (e.g., 'admin' matches '/admin', '/admin/login'). + 'critical_paths' => [ + 'admin', + 'wp-admin', + 'wp-login.php', + 'administrator', + 'phpmyadmin', + '.env', + '.git', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Workspace Cache Configuration + |-------------------------------------------------------------------------- + | + | Configure workspace-scoped caching for multi-tenant resources. + | Models using the BelongsToWorkspace trait can cache their collections + | with automatic invalidation when records are created, updated, or deleted. + | + | The cache system supports both tagged cache stores (Redis, Memcached) + | and non-tagged stores (file, database, array). Tagged stores provide + | more efficient cache invalidation. + | + */ + + 'workspace_cache' => [ + // Whether to enable workspace-scoped caching. + // Set to false to completely disable caching (all queries hit the database). + 'enabled' => env('CORE_WORKSPACE_CACHE_ENABLED', true), + + // Default TTL in seconds for cached workspace queries. + // Individual queries can override this with their own TTL. + 'ttl' => env('CORE_WORKSPACE_CACHE_TTL', 300), + + // Cache key prefix to avoid collisions with other cache keys. + // Change this if you need to separate cache data between deployments. + 'prefix' => env('CORE_WORKSPACE_CACHE_PREFIX', 'workspace_cache'), + + // Whether to use cache tags if available. + // Tags provide more efficient cache invalidation (flush by workspace or model). + // Only works with tag-supporting stores (Redis, Memcached). + // Set to false to always use key-based cache management. + 'use_tags' => env('CORE_WORKSPACE_CACHE_USE_TAGS', true), + ], + + /* + |-------------------------------------------------------------------------- + | Activity Logging Configuration + |-------------------------------------------------------------------------- + | + | Configure activity logging for audit trails across modules. + | Uses spatie/laravel-activitylog under the hood with workspace-aware + | enhancements for multi-tenant environments. + | + | Models can use the Core\Activity\Concerns\LogsActivity trait to + | automatically log create, update, and delete operations. + | + */ + + 'activity' => [ + // Whether to enable activity logging globally. + // Set to false to completely disable activity logging. + 'enabled' => env('CORE_ACTIVITY_ENABLED', true), + + // The log name to use for activities. + // Different log names can be used to separate activities by context. + 'log_name' => env('CORE_ACTIVITY_LOG_NAME', 'default'), + + // Whether to include workspace_id in activity properties. + // Enable this in multi-tenant applications to scope activities per workspace. + 'include_workspace' => env('CORE_ACTIVITY_INCLUDE_WORKSPACE', true), + + // Default events to log when using the LogsActivity trait. + // Models can override this with the $activityLogEvents property. + 'default_events' => ['created', 'updated', 'deleted'], + + // Number of days to retain activity logs. + // Use the activity:prune command to clean up old logs. + // Set to 0 to disable automatic pruning. + 'retention_days' => env('CORE_ACTIVITY_RETENTION_DAYS', 90), + + // Custom Activity model class (optional). + // Set this to use a custom Activity model with additional scopes. + // Default: Core\Activity\Models\Activity::class + 'activity_model' => env('CORE_ACTIVITY_MODEL', \Core\Activity\Models\Activity::class), + ], + +]; diff --git a/container.go b/container.go deleted file mode 100644 index 1df5dea..0000000 --- a/container.go +++ /dev/null @@ -1,451 +0,0 @@ -package php - -import ( - "context" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// DockerBuildOptions configures Docker image building for PHP projects. -type DockerBuildOptions struct { - // ProjectDir is the path to the PHP/Laravel project. - ProjectDir string - - // ImageName is the name for the Docker image. - ImageName string - - // Tag is the image tag (default: "latest"). - Tag string - - // Platform specifies the target platform (e.g., "linux/amd64", "linux/arm64"). - Platform string - - // Dockerfile is the path to a custom Dockerfile. - // If empty, one will be auto-generated for FrankenPHP. - Dockerfile string - - // NoBuildCache disables Docker build cache. - NoBuildCache bool - - // BuildArgs are additional build arguments. - BuildArgs map[string]string - - // Output is the writer for build output (default: os.Stdout). - Output io.Writer -} - -// LinuxKitBuildOptions configures LinuxKit image building for PHP projects. -type LinuxKitBuildOptions struct { - // ProjectDir is the path to the PHP/Laravel project. - ProjectDir string - - // OutputPath is the path for the output image. - OutputPath string - - // Format is the output format: "iso", "qcow2", "raw", "vmdk". - Format string - - // Template is the LinuxKit template name (default: "server-php"). - Template string - - // Variables are template variables to apply. - Variables map[string]string - - // Output is the writer for build output (default: os.Stdout). - Output io.Writer -} - -// ServeOptions configures running a production PHP container. -type ServeOptions struct { - // ImageName is the Docker image to run. - ImageName string - - // Tag is the image tag (default: "latest"). - Tag string - - // ContainerName is the name for the container. - ContainerName string - - // Port is the host port to bind (default: 80). - Port int - - // HTTPSPort is the host HTTPS port to bind (default: 443). - HTTPSPort int - - // Detach runs the container in detached mode. - Detach bool - - // EnvFile is the path to an environment file. - EnvFile string - - // Volumes maps host paths to container paths. - Volumes map[string]string - - // Output is the writer for output (default: os.Stdout). - Output io.Writer -} - -// BuildDocker builds a Docker image for the PHP project. -func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { - if opts.ProjectDir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.ProjectDir = cwd - } - - // Validate project directory - if !IsPHPProject(opts.ProjectDir) { - return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir) - } - - // Set defaults - if opts.ImageName == "" { - opts.ImageName = filepath.Base(opts.ProjectDir) - } - if opts.Tag == "" { - opts.Tag = "latest" - } - if opts.Output == nil { - opts.Output = os.Stdout - } - - // Determine Dockerfile path - dockerfilePath := opts.Dockerfile - var tempDockerfile string - - if dockerfilePath == "" { - // Generate Dockerfile - content, err := GenerateDockerfile(opts.ProjectDir) - if err != nil { - return cli.WrapVerb(err, "generate", "Dockerfile") - } - - // Write to temporary file - m := getMedium() - tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated") - if err := m.Write(tempDockerfile, content); err != nil { - return cli.WrapVerb(err, "write", "Dockerfile") - } - defer func() { _ = m.Delete(tempDockerfile) }() - - dockerfilePath = tempDockerfile - } - - // Build Docker image - imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag) - - args := []string{"build", "-t", imageRef, "-f", dockerfilePath} - - if opts.Platform != "" { - args = append(args, "--platform", opts.Platform) - } - - if opts.NoBuildCache { - args = append(args, "--no-cache") - } - - for key, value := range opts.BuildArgs { - args = append(args, "--build-arg", cli.Sprintf("%s=%s", key, value)) - } - - args = append(args, opts.ProjectDir) - - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Dir = opts.ProjectDir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - if err := cmd.Run(); err != nil { - return cli.Wrap(err, "docker build failed") - } - - return nil -} - -// BuildLinuxKit builds a LinuxKit image for the PHP project. -func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { - if opts.ProjectDir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.ProjectDir = cwd - } - - // Validate project directory - if !IsPHPProject(opts.ProjectDir) { - return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir) - } - - // Set defaults - if opts.Template == "" { - opts.Template = "server-php" - } - if opts.Format == "" { - opts.Format = "qcow2" - } - if opts.OutputPath == "" { - opts.OutputPath = filepath.Join(opts.ProjectDir, "dist", filepath.Base(opts.ProjectDir)) - } - if opts.Output == nil { - opts.Output = os.Stdout - } - - // Ensure output directory exists - m := getMedium() - outputDir := filepath.Dir(opts.OutputPath) - if err := m.EnsureDir(outputDir); err != nil { - return cli.WrapVerb(err, "create", "output directory") - } - - // Find linuxkit binary - linuxkitPath, err := lookupLinuxKit() - if err != nil { - return err - } - - // Get template content - templateContent, err := getLinuxKitTemplate(opts.Template) - if err != nil { - return cli.WrapVerb(err, "get", "template") - } - - // Apply variables - if opts.Variables == nil { - opts.Variables = make(map[string]string) - } - // Add project-specific variables - opts.Variables["PROJECT_DIR"] = opts.ProjectDir - opts.Variables["PROJECT_NAME"] = filepath.Base(opts.ProjectDir) - - content, err := applyTemplateVariables(templateContent, opts.Variables) - if err != nil { - return cli.WrapVerb(err, "apply", "template variables") - } - - // Write template to temp file - tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml") - if err := m.Write(tempYAML, content); err != nil { - return cli.WrapVerb(err, "write", "template") - } - defer func() { _ = m.Delete(tempYAML) }() - - // Build LinuxKit image - args := []string{ - "build", - "--format", opts.Format, - "--name", opts.OutputPath, - tempYAML, - } - - cmd := exec.CommandContext(ctx, linuxkitPath, args...) - cmd.Dir = opts.ProjectDir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - if err := cmd.Run(); err != nil { - return cli.Wrap(err, "linuxkit build failed") - } - - return nil -} - -// ServeProduction runs a production PHP container. -func ServeProduction(ctx context.Context, opts ServeOptions) error { - if opts.ImageName == "" { - return cli.Err("image name is required") - } - - // Set defaults - if opts.Tag == "" { - opts.Tag = "latest" - } - if opts.Port == 0 { - opts.Port = 80 - } - if opts.HTTPSPort == 0 { - opts.HTTPSPort = 443 - } - if opts.Output == nil { - opts.Output = os.Stdout - } - - imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag) - - args := []string{"run"} - - if opts.Detach { - args = append(args, "-d") - } else { - args = append(args, "--rm") - } - - if opts.ContainerName != "" { - args = append(args, "--name", opts.ContainerName) - } - - // Port mappings - args = append(args, "-p", cli.Sprintf("%d:80", opts.Port)) - args = append(args, "-p", cli.Sprintf("%d:443", opts.HTTPSPort)) - - // Environment file - if opts.EnvFile != "" { - args = append(args, "--env-file", opts.EnvFile) - } - - // Volume mounts - for hostPath, containerPath := range opts.Volumes { - args = append(args, "-v", cli.Sprintf("%s:%s", hostPath, containerPath)) - } - - args = append(args, imageRef) - - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - if opts.Detach { - output, err := cmd.Output() - if err != nil { - return cli.WrapVerb(err, "start", "container") - } - containerID := strings.TrimSpace(string(output)) - cli.Print("Container started: %s\n", containerID[:12]) - return nil - } - - return cmd.Run() -} - -// Shell opens a shell in a running container. -func Shell(ctx context.Context, containerID string) error { - if containerID == "" { - return cli.Err("container ID is required") - } - - // Resolve partial container ID - fullID, err := resolveDockerContainerID(ctx, containerID) - if err != nil { - return err - } - - cmd := exec.CommandContext(ctx, "docker", "exec", "-it", fullID, "/bin/sh") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - -// IsPHPProject checks if the given directory is a PHP project. -func IsPHPProject(dir string) bool { - composerPath := filepath.Join(dir, "composer.json") - return getMedium().IsFile(composerPath) -} - -// commonLinuxKitPaths defines default search locations for linuxkit. -var commonLinuxKitPaths = []string{ - "/usr/local/bin/linuxkit", - "/opt/homebrew/bin/linuxkit", -} - -// lookupLinuxKit finds the linuxkit binary. -func lookupLinuxKit() (string, error) { - // Check PATH first - if path, err := exec.LookPath("linuxkit"); err == nil { - return path, nil - } - - m := getMedium() - for _, p := range commonLinuxKitPaths { - if m.IsFile(p) { - return p, nil - } - } - - return "", cli.Err("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit") -} - -// getLinuxKitTemplate retrieves a LinuxKit template by name. -func getLinuxKitTemplate(name string) (string, error) { - // Default server-php template for PHP projects - if name == "server-php" { - return defaultServerPHPTemplate, nil - } - - // Try to load from container package templates - // This would integrate with forge.lthn.ai/core/go/pkg/container - return "", cli.Err("template not found: %s", name) -} - -// applyTemplateVariables applies variable substitution to template content. -func applyTemplateVariables(content string, vars map[string]string) (string, error) { - result := content - for key, value := range vars { - placeholder := "${" + key + "}" - result = strings.ReplaceAll(result, placeholder, value) - } - return result, nil -} - -// resolveDockerContainerID resolves a partial container ID to a full ID. -func resolveDockerContainerID(ctx context.Context, partialID string) (string, error) { - cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}") - output, err := cmd.Output() - if err != nil { - return "", cli.WrapVerb(err, "list", "containers") - } - - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - var matches []string - - for _, line := range lines { - if strings.HasPrefix(line, partialID) { - matches = append(matches, line) - } - } - - switch len(matches) { - case 0: - return "", cli.Err("no container found matching: %s", partialID) - case 1: - return matches[0], nil - default: - return "", cli.Err("multiple containers match '%s', be more specific", partialID) - } -} - -// defaultServerPHPTemplate is the default LinuxKit template for PHP servers. -const defaultServerPHPTemplate = `# LinuxKit configuration for PHP/FrankenPHP server -kernel: - image: linuxkit/kernel:6.6.13 - cmdline: "console=tty0 console=ttyS0" -init: - - linuxkit/init:v1.0.1 - - linuxkit/runc:v1.0.1 - - linuxkit/containerd:v1.0.1 -onboot: - - name: sysctl - image: linuxkit/sysctl:v1.0.1 - - name: dhcpcd - image: linuxkit/dhcpcd:v1.0.1 - command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"] -services: - - name: getty - image: linuxkit/getty:v1.0.1 - env: - - INSECURE=true - - name: sshd - image: linuxkit/sshd:v1.0.1 -files: - - path: etc/ssh/authorized_keys - contents: | - ${SSH_KEY:-} -` diff --git a/container_test.go b/container_test.go deleted file mode 100644 index c0d0e19..0000000 --- a/container_test.go +++ /dev/null @@ -1,383 +0,0 @@ -package php - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDockerBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := DockerBuildOptions{ - ProjectDir: "/project", - ImageName: "myapp", - Tag: "v1.0.0", - Platform: "linux/amd64", - Dockerfile: "/path/to/Dockerfile", - NoBuildCache: true, - BuildArgs: map[string]string{"ARG1": "value1"}, - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "v1.0.0", opts.Tag) - assert.Equal(t, "linux/amd64", opts.Platform) - assert.Equal(t, "/path/to/Dockerfile", opts.Dockerfile) - assert.True(t, opts.NoBuildCache) - assert.Equal(t, "value1", opts.BuildArgs["ARG1"]) - assert.NotNil(t, opts.Output) - }) -} - -func TestLinuxKitBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := LinuxKitBuildOptions{ - ProjectDir: "/project", - OutputPath: "/output/image.qcow2", - Format: "qcow2", - Template: "server-php", - Variables: map[string]string{"VAR1": "value1"}, - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "/output/image.qcow2", opts.OutputPath) - assert.Equal(t, "qcow2", opts.Format) - assert.Equal(t, "server-php", opts.Template) - assert.Equal(t, "value1", opts.Variables["VAR1"]) - assert.NotNil(t, opts.Output) - }) -} - -func TestServeOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := ServeOptions{ - ImageName: "myapp", - Tag: "latest", - ContainerName: "myapp-container", - Port: 8080, - HTTPSPort: 8443, - Detach: true, - EnvFile: "/path/to/.env", - Volumes: map[string]string{"/host": "/container"}, - Output: os.Stdout, - } - - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "latest", opts.Tag) - assert.Equal(t, "myapp-container", opts.ContainerName) - assert.Equal(t, 8080, opts.Port) - assert.Equal(t, 8443, opts.HTTPSPort) - assert.True(t, opts.Detach) - assert.Equal(t, "/path/to/.env", opts.EnvFile) - assert.Equal(t, "/container", opts.Volumes["/host"]) - assert.NotNil(t, opts.Output) - }) -} - -func TestIsPHPProject_Container_Good(t *testing.T) { - t.Run("returns true with composer.json", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{}`), 0644) - require.NoError(t, err) - - assert.True(t, IsPHPProject(dir)) - }) -} - -func TestIsPHPProject_Container_Bad(t *testing.T) { - t.Run("returns false without composer.json", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) - }) - - t.Run("returns false for non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) - }) -} - -func TestLookupLinuxKit_Bad(t *testing.T) { - t.Run("returns error when linuxkit not found", func(t *testing.T) { - // Save original PATH and paths - origPath := os.Getenv("PATH") - origCommonPaths := commonLinuxKitPaths - defer func() { - _ = os.Setenv("PATH", origPath) - commonLinuxKitPaths = origCommonPaths - }() - - // Set PATH to empty and clear common paths - _ = os.Setenv("PATH", "") - commonLinuxKitPaths = []string{} - - _, err := lookupLinuxKit() - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "linuxkit not found") - } - }) -} - -func TestGetLinuxKitTemplate_Good(t *testing.T) { - t.Run("returns server-php template", func(t *testing.T) { - content, err := getLinuxKitTemplate("server-php") - assert.NoError(t, err) - assert.Contains(t, content, "kernel:") - assert.Contains(t, content, "linuxkit/kernel") - }) -} - -func TestGetLinuxKitTemplate_Bad(t *testing.T) { - t.Run("returns error for unknown template", func(t *testing.T) { - _, err := getLinuxKitTemplate("unknown-template") - assert.Error(t, err) - assert.Contains(t, err.Error(), "template not found") - }) -} - -func TestApplyTemplateVariables_Good(t *testing.T) { - t.Run("replaces variables", func(t *testing.T) { - content := "Hello ${NAME}, welcome to ${PLACE}!" - vars := map[string]string{ - "NAME": "World", - "PLACE": "Earth", - } - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "Hello World, welcome to Earth!", result) - }) - - t.Run("handles empty variables", func(t *testing.T) { - content := "No variables here" - vars := map[string]string{} - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "No variables here", result) - }) - - t.Run("leaves unmatched placeholders", func(t *testing.T) { - content := "Hello ${NAME}, ${UNKNOWN} is unknown" - vars := map[string]string{ - "NAME": "World", - } - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Contains(t, result, "Hello World") - assert.Contains(t, result, "${UNKNOWN}") - }) - - t.Run("handles multiple occurrences", func(t *testing.T) { - content := "${VAR} and ${VAR} again" - vars := map[string]string{ - "VAR": "value", - } - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "value and value again", result) - }) -} - -func TestDefaultServerPHPTemplate_Good(t *testing.T) { - t.Run("template has required sections", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "kernel:") - assert.Contains(t, defaultServerPHPTemplate, "init:") - assert.Contains(t, defaultServerPHPTemplate, "services:") - assert.Contains(t, defaultServerPHPTemplate, "onboot:") - }) - - t.Run("template contains placeholders", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "${SSH_KEY:-}") - }) -} - -func TestBuildDocker_Bad(t *testing.T) { - t.Skip("requires Docker installed") - - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) -} - -func TestBuildLinuxKit_Bad(t *testing.T) { - t.Skip("requires linuxkit installed") - - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) -} - -func TestServeProduction_Bad(t *testing.T) { - t.Run("fails without image name", func(t *testing.T) { - err := ServeProduction(context.TODO(), ServeOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "image name is required") - }) -} - -func TestShell_Bad(t *testing.T) { - t.Run("fails without container ID", func(t *testing.T) { - err := Shell(context.TODO(), "") - assert.Error(t, err) - assert.Contains(t, err.Error(), "container ID is required") - }) -} - -func TestResolveDockerContainerID_Bad(t *testing.T) { - t.Skip("requires Docker installed") -} - -func TestBuildDocker_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { - // This tests the default logic without actually running Docker - opts := DockerBuildOptions{} - - // Verify default values would be set in BuildDocker - if opts.Tag == "" { - opts.Tag = "latest" - } - assert.Equal(t, "latest", opts.Tag) - - if opts.ImageName == "" { - opts.ImageName = filepath.Base("/project/myapp") - } - assert.Equal(t, "myapp", opts.ImageName) - }) -} - -func TestBuildLinuxKit_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { - opts := LinuxKitBuildOptions{} - - // Verify default values would be set - if opts.Template == "" { - opts.Template = "server-php" - } - assert.Equal(t, "server-php", opts.Template) - - if opts.Format == "" { - opts.Format = "qcow2" - } - assert.Equal(t, "qcow2", opts.Format) - }) -} - -func TestServeProduction_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { - opts := ServeOptions{ImageName: "myapp"} - - // Verify default values would be set - if opts.Tag == "" { - opts.Tag = "latest" - } - assert.Equal(t, "latest", opts.Tag) - - if opts.Port == 0 { - opts.Port = 80 - } - assert.Equal(t, 80, opts.Port) - - if opts.HTTPSPort == 0 { - opts.HTTPSPort = 443 - } - assert.Equal(t, 443, opts.HTTPSPort) - }) -} - -func TestLookupLinuxKit_Good(t *testing.T) { - t.Skip("requires linuxkit installed") - - t.Run("finds linuxkit in PATH", func(t *testing.T) { - path, err := lookupLinuxKit() - assert.NoError(t, err) - assert.NotEmpty(t, path) - }) -} - -func TestBuildDocker_WithCustomDockerfile(t *testing.T) { - t.Skip("requires Docker installed") - - t.Run("uses custom Dockerfile when provided", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"test"}`), 0644) - require.NoError(t, err) - - dockerfilePath := filepath.Join(dir, "Dockerfile.custom") - err = os.WriteFile(dockerfilePath, []byte("FROM alpine"), 0644) - require.NoError(t, err) - - opts := DockerBuildOptions{ - ProjectDir: dir, - Dockerfile: dockerfilePath, - } - - // The function would use the custom Dockerfile - assert.Equal(t, dockerfilePath, opts.Dockerfile) - }) -} - -func TestBuildDocker_GeneratesDockerfile(t *testing.T) { - t.Skip("requires Docker installed") - - t.Run("generates Dockerfile when not provided", func(t *testing.T) { - dir := t.TempDir() - - // Create valid PHP project - composerJSON := `{"name":"test","require":{"php":"^8.2","laravel/framework":"^11.0"}}` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - opts := DockerBuildOptions{ - ProjectDir: dir, - // Dockerfile not specified - should be generated - } - - assert.Empty(t, opts.Dockerfile) - }) -} - -func TestServeProduction_BuildsCorrectArgs(t *testing.T) { - t.Run("builds correct docker run arguments", func(t *testing.T) { - opts := ServeOptions{ - ImageName: "myapp", - Tag: "v1.0.0", - ContainerName: "myapp-prod", - Port: 8080, - HTTPSPort: 8443, - Detach: true, - EnvFile: "/path/.env", - Volumes: map[string]string{ - "/host/storage": "/app/storage", - }, - } - - // Verify the expected image reference format - imageRef := opts.ImageName + ":" + opts.Tag - assert.Equal(t, "myapp:v1.0.0", imageRef) - - // Verify port format - portMapping := opts.Port - assert.Equal(t, 8080, portMapping) - }) -} - -func TestShell_Integration(t *testing.T) { - t.Skip("requires Docker with running container") -} - -func TestResolveDockerContainerID_Integration(t *testing.T) { - t.Skip("requires Docker with running containers") -} diff --git a/coolify.go b/coolify.go deleted file mode 100644 index fd08a06..0000000 --- a/coolify.go +++ /dev/null @@ -1,351 +0,0 @@ -package php - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// CoolifyClient is an HTTP client for the Coolify API. -type CoolifyClient struct { - BaseURL string - Token string - HTTPClient *http.Client -} - -// CoolifyConfig holds configuration loaded from environment. -type CoolifyConfig struct { - URL string - Token string - AppID string - StagingAppID string -} - -// CoolifyDeployment represents a deployment from the Coolify API. -type CoolifyDeployment struct { - ID string `json:"id"` - Status string `json:"status"` - CommitSHA string `json:"commit_sha,omitempty"` - CommitMsg string `json:"commit_message,omitempty"` - Branch string `json:"branch,omitempty"` - CreatedAt time.Time `json:"created_at"` - FinishedAt time.Time `json:"finished_at,omitempty"` - Log string `json:"log,omitempty"` - DeployedURL string `json:"deployed_url,omitempty"` -} - -// CoolifyApp represents an application from the Coolify API. -type CoolifyApp struct { - ID string `json:"id"` - Name string `json:"name"` - FQDN string `json:"fqdn,omitempty"` - Status string `json:"status,omitempty"` - Repository string `json:"repository,omitempty"` - Branch string `json:"branch,omitempty"` - Environment string `json:"environment,omitempty"` -} - -// NewCoolifyClient creates a new Coolify API client. -func NewCoolifyClient(baseURL, token string) *CoolifyClient { - // Ensure baseURL doesn't have trailing slash - baseURL = strings.TrimSuffix(baseURL, "/") - - return &CoolifyClient{ - BaseURL: baseURL, - Token: token, - HTTPClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// LoadCoolifyConfig loads Coolify configuration from .env file in the given directory. -func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) { - envPath := filepath.Join(dir, ".env") - return LoadCoolifyConfigFromFile(envPath) -} - -// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file. -func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { - m := getMedium() - config := &CoolifyConfig{} - - // First try environment variables - config.URL = os.Getenv("COOLIFY_URL") - config.Token = os.Getenv("COOLIFY_TOKEN") - config.AppID = os.Getenv("COOLIFY_APP_ID") - config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID") - - // Then try .env file - if !m.Exists(path) { - // No .env file, just use env vars - return validateCoolifyConfig(config) - } - - content, err := m.Read(path) - if err != nil { - return nil, cli.WrapVerb(err, "read", ".env file") - } - - // Parse .env file - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - // Remove quotes if present - value = strings.Trim(value, `"'`) - - // Only override if not already set from env - switch key { - case "COOLIFY_URL": - if config.URL == "" { - config.URL = value - } - case "COOLIFY_TOKEN": - if config.Token == "" { - config.Token = value - } - case "COOLIFY_APP_ID": - if config.AppID == "" { - config.AppID = value - } - case "COOLIFY_STAGING_APP_ID": - if config.StagingAppID == "" { - config.StagingAppID = value - } - } - } - - return validateCoolifyConfig(config) -} - -// validateCoolifyConfig checks that required fields are set. -func validateCoolifyConfig(config *CoolifyConfig) (*CoolifyConfig, error) { - if config.URL == "" { - return nil, cli.Err("COOLIFY_URL is not set") - } - if config.Token == "" { - return nil, cli.Err("COOLIFY_TOKEN is not set") - } - return config, nil -} - -// TriggerDeploy triggers a deployment for the specified application. -func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force bool) (*CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/deploy", c.BaseURL, appID) - - payload := map[string]interface{}{} - if force { - payload["force"] = true - } - - body, err := json.Marshal(payload) - if err != nil { - return nil, cli.WrapVerb(err, "marshal", "request") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { - return nil, c.parseError(resp) - } - - var deployment CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil { - // Some Coolify versions return minimal response - return &CoolifyDeployment{ - Status: "queued", - CreatedAt: time.Now(), - }, nil - } - - return &deployment, nil -} - -// GetDeployment retrieves a specific deployment by ID. -func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments/%s", c.BaseURL, appID, deploymentID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, c.parseError(resp) - } - - var deployment CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil { - return nil, cli.WrapVerb(err, "decode", "response") - } - - return &deployment, nil -} - -// ListDeployments retrieves deployments for an application. -func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit int) ([]CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments", c.BaseURL, appID) - if limit > 0 { - endpoint = cli.Sprintf("%s?limit=%d", endpoint, limit) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, c.parseError(resp) - } - - var deployments []CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployments); err != nil { - return nil, cli.WrapVerb(err, "decode", "response") - } - - return deployments, nil -} - -// Rollback triggers a rollback to a previous deployment. -func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/rollback", c.BaseURL, appID) - - payload := map[string]interface{}{ - "deployment_id": deploymentID, - } - - body, err := json.Marshal(payload) - if err != nil { - return nil, cli.WrapVerb(err, "marshal", "request") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { - return nil, c.parseError(resp) - } - - var deployment CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil { - return &CoolifyDeployment{ - Status: "rolling_back", - CreatedAt: time.Now(), - }, nil - } - - return &deployment, nil -} - -// GetApp retrieves application details. -func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s", c.BaseURL, appID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, c.parseError(resp) - } - - var app CoolifyApp - if err := json.NewDecoder(resp.Body).Decode(&app); err != nil { - return nil, cli.WrapVerb(err, "decode", "response") - } - - return &app, nil -} - -// setHeaders sets common headers for API requests. -func (c *CoolifyClient) setHeaders(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+c.Token) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") -} - -// parseError extracts error information from an API response. -func (c *CoolifyClient) parseError(resp *http.Response) error { - body, _ := io.ReadAll(resp.Body) - - var errResp struct { - Message string `json:"message"` - Error string `json:"error"` - } - - if err := json.Unmarshal(body, &errResp); err == nil { - if errResp.Message != "" { - return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Message) - } - if errResp.Error != "" { - return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Error) - } - } - - return cli.Err("API error (%d): %s", resp.StatusCode, string(body)) -} diff --git a/coolify_test.go b/coolify_test.go deleted file mode 100644 index 8176c88..0000000 --- a/coolify_test.go +++ /dev/null @@ -1,502 +0,0 @@ -package php - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCoolifyClient_Good(t *testing.T) { - t.Run("creates client with correct base URL", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com", "token") - - assert.Equal(t, "https://coolify.example.com", client.BaseURL) - assert.Equal(t, "token", client.Token) - assert.NotNil(t, client.HTTPClient) - }) - - t.Run("strips trailing slash from base URL", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com/", "token") - assert.Equal(t, "https://coolify.example.com", client.BaseURL) - }) - - t.Run("http client has timeout", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com", "token") - assert.Equal(t, 30*time.Second, client.HTTPClient.Timeout) - }) -} - -func TestCoolifyConfig_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - config := CoolifyConfig{ - URL: "https://coolify.example.com", - Token: "secret-token", - AppID: "app-123", - StagingAppID: "staging-456", - } - - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) - }) -} - -func TestCoolifyDeployment_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - now := time.Now() - deployment := CoolifyDeployment{ - ID: "dep-123", - Status: "finished", - CommitSHA: "abc123", - CommitMsg: "Test commit", - Branch: "main", - CreatedAt: now, - FinishedAt: now.Add(5 * time.Minute), - Log: "Build successful", - DeployedURL: "https://app.example.com", - } - - assert.Equal(t, "dep-123", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) - assert.Equal(t, "Test commit", deployment.CommitMsg) - assert.Equal(t, "main", deployment.Branch) - }) -} - -func TestCoolifyApp_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - app := CoolifyApp{ - ID: "app-123", - Name: "MyApp", - FQDN: "https://myapp.example.com", - Status: "running", - Repository: "https://github.com/user/repo", - Branch: "main", - Environment: "production", - } - - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) - assert.Equal(t, "running", app.Status) - }) -} - -func TestLoadCoolifyConfigFromFile_Good(t *testing.T) { - t.Run("loads config from .env file", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com -COOLIFY_TOKEN=secret-token -COOLIFY_APP_ID=app-123 -COOLIFY_STAGING_APP_ID=staging-456` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) - }) - - t.Run("handles quoted values", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL="https://coolify.example.com" -COOLIFY_TOKEN='secret-token'` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - }) - - t.Run("ignores comments", func(t *testing.T) { - dir := t.TempDir() - envContent := `# This is a comment -COOLIFY_URL=https://coolify.example.com -# COOLIFY_TOKEN=wrong-token -COOLIFY_TOKEN=correct-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "correct-token", config.Token) - }) - - t.Run("ignores blank lines", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com - -COOLIFY_TOKEN=secret-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - }) -} - -func TestLoadCoolifyConfigFromFile_Bad(t *testing.T) { - t.Run("fails when COOLIFY_URL missing", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_TOKEN=secret-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") - }) - - t.Run("fails when COOLIFY_TOKEN missing", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") - }) -} - -func TestLoadCoolifyConfig_FromDirectory_Good(t *testing.T) { - t.Run("loads from directory", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com -COOLIFY_TOKEN=secret-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - }) -} - -func TestValidateCoolifyConfig_Bad(t *testing.T) { - t.Run("returns error for empty URL", func(t *testing.T) { - config := &CoolifyConfig{Token: "token"} - _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") - }) - - t.Run("returns error for empty token", func(t *testing.T) { - config := &CoolifyConfig{URL: "https://coolify.example.com"} - _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") - }) -} - -func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { - t.Run("triggers deployment successfully", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deploy", r.URL.Path) - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - resp := CoolifyDeployment{ - ID: "dep-456", - Status: "queued", - CreatedAt: time.Now(), - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "queued", deployment.Status) - }) - - t.Run("triggers deployment with force", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, true, body["force"]) - - resp := CoolifyDeployment{ID: "dep-456", Status: "queued"} - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.TriggerDeploy(context.Background(), "app-123", true) - assert.NoError(t, err) - }) - - t.Run("handles minimal response", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Return an invalid JSON response to trigger the fallback - _, _ = w.Write([]byte("not json")) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - - assert.NoError(t, err) - // The fallback response should be returned - assert.Equal(t, "queued", deployment.Status) - }) -} - -func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) { - t.Run("fails on HTTP error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "Internal error"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.TriggerDeploy(context.Background(), "app-123", false) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "API error") - }) -} - -func TestCoolifyClient_GetDeployment_Good(t *testing.T) { - t.Run("gets deployment details", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path) - assert.Equal(t, "GET", r.Method) - - resp := CoolifyDeployment{ - ID: "dep-456", - Status: "finished", - CommitSHA: "abc123", - Branch: "main", - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) - }) -} - -func TestCoolifyClient_GetDeployment_Bad(t *testing.T) { - t.Run("fails on 404", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Not found"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Not found") - }) -} - -func TestCoolifyClient_ListDeployments_Good(t *testing.T) { - t.Run("lists deployments", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments", r.URL.Path) - assert.Equal(t, "10", r.URL.Query().Get("limit")) - - resp := []CoolifyDeployment{ - {ID: "dep-1", Status: "finished"}, - {ID: "dep-2", Status: "failed"}, - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployments, err := client.ListDeployments(context.Background(), "app-123", 10) - - assert.NoError(t, err) - assert.Len(t, deployments, 2) - assert.Equal(t, "dep-1", deployments[0].ID) - assert.Equal(t, "dep-2", deployments[1].ID) - }) - - t.Run("lists without limit", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "", r.URL.Query().Get("limit")) - _ = json.NewEncoder(w).Encode([]CoolifyDeployment{}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.ListDeployments(context.Background(), "app-123", 0) - assert.NoError(t, err) - }) -} - -func TestCoolifyClient_Rollback_Good(t *testing.T) { - t.Run("triggers rollback", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/rollback", r.URL.Path) - assert.Equal(t, "POST", r.Method) - - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, "dep-old", body["deployment_id"]) - - resp := CoolifyDeployment{ - ID: "dep-new", - Status: "rolling_back", - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.Rollback(context.Background(), "app-123", "dep-old") - - assert.NoError(t, err) - assert.Equal(t, "dep-new", deployment.ID) - assert.Equal(t, "rolling_back", deployment.Status) - }) -} - -func TestCoolifyClient_GetApp_Good(t *testing.T) { - t.Run("gets app details", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123", r.URL.Path) - assert.Equal(t, "GET", r.Method) - - resp := CoolifyApp{ - ID: "app-123", - Name: "MyApp", - FQDN: "https://myapp.example.com", - Status: "running", - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - app, err := client.GetApp(context.Background(), "app-123") - - assert.NoError(t, err) - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) - }) -} - -func TestCoolifyClient_SetHeaders(t *testing.T) { - t.Run("sets all required headers", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com", "my-token") - req, _ := http.NewRequest("GET", "https://coolify.example.com", nil) - - client.setHeaders(req) - - assert.Equal(t, "Bearer my-token", req.Header.Get("Authorization")) - assert.Equal(t, "application/json", req.Header.Get("Content-Type")) - assert.Equal(t, "application/json", req.Header.Get("Accept")) - }) -} - -func TestCoolifyClient_ParseError(t *testing.T) { - t.Run("parses message field", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "Bad request message"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Bad request message") - }) - - t.Run("parses error field", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Error message"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Error message") - }) - - t.Run("returns raw body when no JSON fields", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("Raw error message")) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Raw error message") - }) -} - -func TestEnvironmentVariablePriority(t *testing.T) { - t.Run("env vars take precedence over .env file", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://from-file.com -COOLIFY_TOKEN=file-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - // Set environment variables - origURL := os.Getenv("COOLIFY_URL") - origToken := os.Getenv("COOLIFY_TOKEN") - defer func() { - _ = os.Setenv("COOLIFY_URL", origURL) - _ = os.Setenv("COOLIFY_TOKEN", origToken) - }() - - _ = os.Setenv("COOLIFY_URL", "https://from-env.com") - _ = os.Setenv("COOLIFY_TOKEN", "env-token") - - config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) - // Environment variables should take precedence - assert.Equal(t, "https://from-env.com", config.URL) - assert.Equal(t, "env-token", config.Token) - }) -} diff --git a/database/migrations/2024_01_01_000001_create_activity_log_table.php b/database/migrations/2024_01_01_000001_create_activity_log_table.php new file mode 100644 index 0000000..92c189b --- /dev/null +++ b/database/migrations/2024_01_01_000001_create_activity_log_table.php @@ -0,0 +1,32 @@ +bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->uuid('batch_uuid')->nullable(); + $table->string('event')->nullable(); + $table->timestamps(); + + $table->index('log_name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('activities'); + } +}; diff --git a/deploy.go b/deploy.go deleted file mode 100644 index 9717ae7..0000000 --- a/deploy.go +++ /dev/null @@ -1,407 +0,0 @@ -package php - -import ( - "context" - "time" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// Environment represents a deployment environment. -type Environment string - -const ( - // EnvProduction is the production environment. - EnvProduction Environment = "production" - // EnvStaging is the staging environment. - EnvStaging Environment = "staging" -) - -// DeployOptions configures a deployment. -type DeployOptions struct { - // Dir is the project directory containing .env config. - Dir string - - // Environment is the target environment (production or staging). - Environment Environment - - // Force triggers a deployment even if no changes are detected. - Force bool - - // Wait blocks until deployment completes. - Wait bool - - // WaitTimeout is the maximum time to wait for deployment. - // Defaults to 10 minutes. - WaitTimeout time.Duration - - // PollInterval is how often to check deployment status when waiting. - // Defaults to 5 seconds. - PollInterval time.Duration -} - -// StatusOptions configures a status check. -type StatusOptions struct { - // Dir is the project directory containing .env config. - Dir string - - // Environment is the target environment (production or staging). - Environment Environment - - // DeploymentID is a specific deployment to check. - // If empty, returns the latest deployment. - DeploymentID string -} - -// RollbackOptions configures a rollback. -type RollbackOptions struct { - // Dir is the project directory containing .env config. - Dir string - - // Environment is the target environment (production or staging). - Environment Environment - - // DeploymentID is the deployment to rollback to. - // If empty, rolls back to the previous successful deployment. - DeploymentID string - - // Wait blocks until rollback completes. - Wait bool - - // WaitTimeout is the maximum time to wait for rollback. - WaitTimeout time.Duration -} - -// DeploymentStatus represents the status of a deployment. -type DeploymentStatus struct { - // ID is the deployment identifier. - ID string - - // Status is the current deployment status. - // Values: queued, building, deploying, finished, failed, cancelled - Status string - - // URL is the deployed application URL. - URL string - - // Commit is the git commit SHA. - Commit string - - // CommitMessage is the git commit message. - CommitMessage string - - // Branch is the git branch. - Branch string - - // StartedAt is when the deployment started. - StartedAt time.Time - - // CompletedAt is when the deployment completed. - CompletedAt time.Time - - // Log contains deployment logs. - Log string -} - -// Deploy triggers a deployment to Coolify. -func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) { - if opts.Dir == "" { - opts.Dir = "." - } - if opts.Environment == "" { - opts.Environment = EnvProduction - } - if opts.WaitTimeout == 0 { - opts.WaitTimeout = 10 * time.Minute - } - if opts.PollInterval == 0 { - opts.PollInterval = 5 * time.Second - } - - // Load config - config, err := LoadCoolifyConfig(opts.Dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, opts.Environment) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - // Trigger deployment - deployment, err := client.TriggerDeploy(ctx, appID, opts.Force) - if err != nil { - return nil, cli.WrapVerb(err, "trigger", "deployment") - } - - status := convertDeployment(deployment) - - // Wait for completion if requested - if opts.Wait && deployment.ID != "" { - status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, opts.PollInterval) - if err != nil { - return status, err - } - } - - // Get app info for URL - app, err := client.GetApp(ctx, appID) - if err == nil && app.FQDN != "" { - status.URL = app.FQDN - } - - return status, nil -} - -// DeployStatus retrieves the status of a deployment. -func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, error) { - if opts.Dir == "" { - opts.Dir = "." - } - if opts.Environment == "" { - opts.Environment = EnvProduction - } - - // Load config - config, err := LoadCoolifyConfig(opts.Dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, opts.Environment) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - var deployment *CoolifyDeployment - - if opts.DeploymentID != "" { - // Get specific deployment - deployment, err = client.GetDeployment(ctx, appID, opts.DeploymentID) - if err != nil { - return nil, cli.WrapVerb(err, "get", "deployment") - } - } else { - // Get latest deployment - deployments, err := client.ListDeployments(ctx, appID, 1) - if err != nil { - return nil, cli.WrapVerb(err, "list", "deployments") - } - if len(deployments) == 0 { - return nil, cli.Err("no deployments found") - } - deployment = &deployments[0] - } - - status := convertDeployment(deployment) - - // Get app info for URL - app, err := client.GetApp(ctx, appID) - if err == nil && app.FQDN != "" { - status.URL = app.FQDN - } - - return status, nil -} - -// Rollback triggers a rollback to a previous deployment. -func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) { - if opts.Dir == "" { - opts.Dir = "." - } - if opts.Environment == "" { - opts.Environment = EnvProduction - } - if opts.WaitTimeout == 0 { - opts.WaitTimeout = 10 * time.Minute - } - - // Load config - config, err := LoadCoolifyConfig(opts.Dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, opts.Environment) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - // Find deployment to rollback to - deploymentID := opts.DeploymentID - if deploymentID == "" { - // Find previous successful deployment - deployments, err := client.ListDeployments(ctx, appID, 10) - if err != nil { - return nil, cli.WrapVerb(err, "list", "deployments") - } - - // Skip the first (current) deployment, find the last successful one - for i, d := range deployments { - if i == 0 { - continue // Skip current deployment - } - if d.Status == "finished" || d.Status == "success" { - deploymentID = d.ID - break - } - } - - if deploymentID == "" { - return nil, cli.Err("no previous successful deployment found to rollback to") - } - } - - // Trigger rollback - deployment, err := client.Rollback(ctx, appID, deploymentID) - if err != nil { - return nil, cli.WrapVerb(err, "trigger", "rollback") - } - - status := convertDeployment(deployment) - - // Wait for completion if requested - if opts.Wait && deployment.ID != "" { - status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, 5*time.Second) - if err != nil { - return status, err - } - } - - return status, nil -} - -// ListDeployments retrieves recent deployments. -func ListDeployments(ctx context.Context, dir string, env Environment, limit int) ([]DeploymentStatus, error) { - if dir == "" { - dir = "." - } - if env == "" { - env = EnvProduction - } - if limit == 0 { - limit = 10 - } - - // Load config - config, err := LoadCoolifyConfig(dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, env) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", env) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - deployments, err := client.ListDeployments(ctx, appID, limit) - if err != nil { - return nil, cli.WrapVerb(err, "list", "deployments") - } - - result := make([]DeploymentStatus, len(deployments)) - for i, d := range deployments { - result[i] = *convertDeployment(&d) - } - - return result, nil -} - -// getAppIDForEnvironment returns the app ID for the given environment. -func getAppIDForEnvironment(config *CoolifyConfig, env Environment) string { - switch env { - case EnvStaging: - if config.StagingAppID != "" { - return config.StagingAppID - } - return config.AppID // Fallback to production - default: - return config.AppID - } -} - -// convertDeployment converts a CoolifyDeployment to DeploymentStatus. -func convertDeployment(d *CoolifyDeployment) *DeploymentStatus { - return &DeploymentStatus{ - ID: d.ID, - Status: d.Status, - URL: d.DeployedURL, - Commit: d.CommitSHA, - CommitMessage: d.CommitMsg, - Branch: d.Branch, - StartedAt: d.CreatedAt, - CompletedAt: d.FinishedAt, - Log: d.Log, - } -} - -// waitForDeployment polls for deployment completion. -func waitForDeployment(ctx context.Context, client *CoolifyClient, appID, deploymentID string, timeout, interval time.Duration) (*DeploymentStatus, error) { - deadline := time.Now().Add(timeout) - - for time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - deployment, err := client.GetDeployment(ctx, appID, deploymentID) - if err != nil { - return nil, cli.WrapVerb(err, "get", "deployment status") - } - - status := convertDeployment(deployment) - - // Check if deployment is complete - switch deployment.Status { - case "finished", "success": - return status, nil - case "failed", "error": - return status, cli.Err("deployment failed: %s", deployment.Status) - case "cancelled": - return status, cli.Err("deployment was cancelled") - } - - // Still in progress, wait and retry - select { - case <-ctx.Done(): - return status, ctx.Err() - case <-time.After(interval): - } - } - - return nil, cli.Err("deployment timed out after %v", timeout) -} - -// IsDeploymentComplete returns true if the status indicates completion. -func IsDeploymentComplete(status string) bool { - switch status { - case "finished", "success", "failed", "error", "cancelled": - return true - default: - return false - } -} - -// IsDeploymentSuccessful returns true if the status indicates success. -func IsDeploymentSuccessful(status string) bool { - return status == "finished" || status == "success" -} diff --git a/deploy_internal_test.go b/deploy_internal_test.go deleted file mode 100644 index 9362aaf..0000000 --- a/deploy_internal_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package php - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestConvertDeployment_Good(t *testing.T) { - t.Run("converts all fields", func(t *testing.T) { - now := time.Now() - coolify := &CoolifyDeployment{ - ID: "dep-123", - Status: "finished", - CommitSHA: "abc123", - CommitMsg: "Test commit", - Branch: "main", - CreatedAt: now, - FinishedAt: now.Add(5 * time.Minute), - Log: "Build successful", - DeployedURL: "https://app.example.com", - } - - status := convertDeployment(coolify) - - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, now, status.StartedAt) - assert.Equal(t, now.Add(5*time.Minute), status.CompletedAt) - assert.Equal(t, "Build successful", status.Log) - }) - - t.Run("handles empty deployment", func(t *testing.T) { - coolify := &CoolifyDeployment{} - status := convertDeployment(coolify) - - assert.Empty(t, status.ID) - assert.Empty(t, status.Status) - }) -} - -func TestDeploymentStatus_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - now := time.Now() - status := DeploymentStatus{ - ID: "dep-123", - Status: "finished", - URL: "https://app.example.com", - Commit: "abc123", - CommitMessage: "Test commit", - Branch: "main", - StartedAt: now, - CompletedAt: now.Add(5 * time.Minute), - Log: "Build log", - } - - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, "Build log", status.Log) - }) -} - -func TestDeployOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := DeployOptions{ - Dir: "/project", - Environment: EnvProduction, - Force: true, - Wait: true, - WaitTimeout: 10 * time.Minute, - PollInterval: 5 * time.Second, - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.True(t, opts.Force) - assert.True(t, opts.Wait) - assert.Equal(t, 10*time.Minute, opts.WaitTimeout) - assert.Equal(t, 5*time.Second, opts.PollInterval) - }) -} - -func TestStatusOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := StatusOptions{ - Dir: "/project", - Environment: EnvStaging, - DeploymentID: "dep-123", - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvStaging, opts.Environment) - assert.Equal(t, "dep-123", opts.DeploymentID) - }) -} - -func TestRollbackOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := RollbackOptions{ - Dir: "/project", - Environment: EnvProduction, - DeploymentID: "dep-old", - Wait: true, - WaitTimeout: 5 * time.Minute, - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.Equal(t, "dep-old", opts.DeploymentID) - assert.True(t, opts.Wait) - assert.Equal(t, 5*time.Minute, opts.WaitTimeout) - }) -} - -func TestEnvironment_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, Environment("production"), EnvProduction) - assert.Equal(t, Environment("staging"), EnvStaging) - }) -} - -func TestGetAppIDForEnvironment_Edge(t *testing.T) { - t.Run("staging without staging ID falls back to production", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - // No StagingAppID set - } - - id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "prod-123", id) - }) - - t.Run("staging with staging ID uses staging", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - StagingAppID: "staging-456", - } - - id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "staging-456", id) - }) - - t.Run("production uses production ID", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - StagingAppID: "staging-456", - } - - id := getAppIDForEnvironment(config, EnvProduction) - assert.Equal(t, "prod-123", id) - }) - - t.Run("unknown environment uses production", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - } - - id := getAppIDForEnvironment(config, "unknown") - assert.Equal(t, "prod-123", id) - }) -} - -func TestIsDeploymentComplete_Edge(t *testing.T) { - tests := []struct { - status string - expected bool - }{ - {"finished", true}, - {"success", true}, - {"failed", true}, - {"error", true}, - {"cancelled", true}, - {"queued", false}, - {"building", false}, - {"deploying", false}, - {"pending", false}, - {"rolling_back", false}, - {"", false}, - {"unknown", false}, - } - - for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { - result := IsDeploymentComplete(tt.status) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsDeploymentSuccessful_Edge(t *testing.T) { - tests := []struct { - status string - expected bool - }{ - {"finished", true}, - {"success", true}, - {"failed", false}, - {"error", false}, - {"cancelled", false}, - {"queued", false}, - {"building", false}, - {"deploying", false}, - {"", false}, - } - - for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { - result := IsDeploymentSuccessful(tt.status) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/deploy_test.go b/deploy_test.go deleted file mode 100644 index 228de7d..0000000 --- a/deploy_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadCoolifyConfig_Good(t *testing.T) { - tests := []struct { - name string - envContent string - wantURL string - wantToken string - wantAppID string - wantStaging string - }{ - { - name: "all values set", - envContent: `COOLIFY_URL=https://coolify.example.com -COOLIFY_TOKEN=secret-token -COOLIFY_APP_ID=app-123 -COOLIFY_STAGING_APP_ID=staging-456`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", - wantStaging: "staging-456", - }, - { - name: "quoted values", - envContent: `COOLIFY_URL="https://coolify.example.com" -COOLIFY_TOKEN='secret-token' -COOLIFY_APP_ID="app-123"`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", - }, - { - name: "with comments and blank lines", - envContent: `# Coolify configuration -COOLIFY_URL=https://coolify.example.com - -# API token -COOLIFY_TOKEN=secret-token -COOLIFY_APP_ID=app-123 -# COOLIFY_STAGING_APP_ID=not-this`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temp directory - dir := t.TempDir() - envPath := filepath.Join(dir, ".env") - - // Write .env file - if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil { - t.Fatalf("failed to write .env: %v", err) - } - - // Load config - config, err := LoadCoolifyConfig(dir) - if err != nil { - t.Fatalf("LoadCoolifyConfig() error = %v", err) - } - - if config.URL != tt.wantURL { - t.Errorf("URL = %q, want %q", config.URL, tt.wantURL) - } - if config.Token != tt.wantToken { - t.Errorf("Token = %q, want %q", config.Token, tt.wantToken) - } - if config.AppID != tt.wantAppID { - t.Errorf("AppID = %q, want %q", config.AppID, tt.wantAppID) - } - if tt.wantStaging != "" && config.StagingAppID != tt.wantStaging { - t.Errorf("StagingAppID = %q, want %q", config.StagingAppID, tt.wantStaging) - } - }) - } -} - -func TestLoadCoolifyConfig_Bad(t *testing.T) { - tests := []struct { - name string - envContent string - wantErr string - }{ - { - name: "missing URL", - envContent: "COOLIFY_TOKEN=secret", - wantErr: "COOLIFY_URL is not set", - }, - { - name: "missing token", - envContent: "COOLIFY_URL=https://coolify.example.com", - wantErr: "COOLIFY_TOKEN is not set", - }, - { - name: "empty values", - envContent: "COOLIFY_URL=\nCOOLIFY_TOKEN=", - wantErr: "COOLIFY_URL is not set", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temp directory - dir := t.TempDir() - envPath := filepath.Join(dir, ".env") - - // Write .env file - if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil { - t.Fatalf("failed to write .env: %v", err) - } - - // Load config - _, err := LoadCoolifyConfig(dir) - if err == nil { - t.Fatal("LoadCoolifyConfig() expected error, got nil") - } - - if err.Error() != tt.wantErr { - t.Errorf("error = %q, want %q", err.Error(), tt.wantErr) - } - }) - } -} - -func TestGetAppIDForEnvironment_Good(t *testing.T) { - config := &CoolifyConfig{ - URL: "https://coolify.example.com", - Token: "token", - AppID: "prod-123", - StagingAppID: "staging-456", - } - - tests := []struct { - name string - env Environment - wantID string - }{ - { - name: "production environment", - env: EnvProduction, - wantID: "prod-123", - }, - { - name: "staging environment", - env: EnvStaging, - wantID: "staging-456", - }, - { - name: "empty defaults to production", - env: "", - wantID: "prod-123", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - id := getAppIDForEnvironment(config, tt.env) - if id != tt.wantID { - t.Errorf("getAppIDForEnvironment() = %q, want %q", id, tt.wantID) - } - }) - } -} - -func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) { - config := &CoolifyConfig{ - URL: "https://coolify.example.com", - Token: "token", - AppID: "prod-123", - // No staging app ID - } - - // Staging should fall back to production - id := getAppIDForEnvironment(config, EnvStaging) - if id != "prod-123" { - t.Errorf("getAppIDForEnvironment(EnvStaging) = %q, want %q (should fallback)", id, "prod-123") - } -} - -func TestIsDeploymentComplete_Good(t *testing.T) { - completeStatuses := []string{"finished", "success", "failed", "error", "cancelled"} - for _, status := range completeStatuses { - if !IsDeploymentComplete(status) { - t.Errorf("IsDeploymentComplete(%q) = false, want true", status) - } - } - - incompleteStatuses := []string{"queued", "building", "deploying", "pending", "rolling_back"} - for _, status := range incompleteStatuses { - if IsDeploymentComplete(status) { - t.Errorf("IsDeploymentComplete(%q) = true, want false", status) - } - } -} - -func TestIsDeploymentSuccessful_Good(t *testing.T) { - successStatuses := []string{"finished", "success"} - for _, status := range successStatuses { - if !IsDeploymentSuccessful(status) { - t.Errorf("IsDeploymentSuccessful(%q) = false, want true", status) - } - } - - failedStatuses := []string{"failed", "error", "cancelled", "queued", "building"} - for _, status := range failedStatuses { - if IsDeploymentSuccessful(status) { - t.Errorf("IsDeploymentSuccessful(%q) = true, want false", status) - } - } -} - -func TestNewCoolifyClient_Good(t *testing.T) { - tests := []struct { - name string - baseURL string - wantBaseURL string - }{ - { - name: "URL without trailing slash", - baseURL: "https://coolify.example.com", - wantBaseURL: "https://coolify.example.com", - }, - { - name: "URL with trailing slash", - baseURL: "https://coolify.example.com/", - wantBaseURL: "https://coolify.example.com", - }, - { - name: "URL with api path", - baseURL: "https://coolify.example.com/api/", - wantBaseURL: "https://coolify.example.com/api", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := NewCoolifyClient(tt.baseURL, "token") - if client.BaseURL != tt.wantBaseURL { - t.Errorf("BaseURL = %q, want %q", client.BaseURL, tt.wantBaseURL) - } - if client.Token != "token" { - t.Errorf("Token = %q, want %q", client.Token, "token") - } - if client.HTTPClient == nil { - t.Error("HTTPClient is nil") - } - }) - } -} diff --git a/detect.go b/detect.go deleted file mode 100644 index c13da9d..0000000 --- a/detect.go +++ /dev/null @@ -1,296 +0,0 @@ -package php - -import ( - "encoding/json" - "path/filepath" - "strings" -) - -// DetectedService represents a service that was detected in a Laravel project. -type DetectedService string - -// Detected service constants for Laravel projects. -const ( - // ServiceFrankenPHP indicates FrankenPHP server is detected. - ServiceFrankenPHP DetectedService = "frankenphp" - // ServiceVite indicates Vite frontend bundler is detected. - ServiceVite DetectedService = "vite" - // ServiceHorizon indicates Laravel Horizon queue dashboard is detected. - ServiceHorizon DetectedService = "horizon" - // ServiceReverb indicates Laravel Reverb WebSocket server is detected. - ServiceReverb DetectedService = "reverb" - // ServiceRedis indicates Redis cache/queue backend is detected. - ServiceRedis DetectedService = "redis" -) - -// IsLaravelProject checks if the given directory is a Laravel project. -// It looks for the presence of artisan file and laravel in composer.json. -func IsLaravelProject(dir string) bool { - m := getMedium() - - // Check for artisan file - artisanPath := filepath.Join(dir, "artisan") - if !m.Exists(artisanPath) { - return false - } - - // Check composer.json for laravel/framework - composerPath := filepath.Join(dir, "composer.json") - data, err := m.Read(composerPath) - if err != nil { - return false - } - - var composer struct { - Require map[string]string `json:"require"` - RequireDev map[string]string `json:"require-dev"` - } - - if err := json.Unmarshal([]byte(data), &composer); err != nil { - return false - } - - // Check for laravel/framework in require - if _, ok := composer.Require["laravel/framework"]; ok { - return true - } - - // Also check require-dev (less common but possible) - if _, ok := composer.RequireDev["laravel/framework"]; ok { - return true - } - - return false -} - -// IsFrankenPHPProject checks if the project is configured for FrankenPHP. -// It looks for laravel/octane with frankenphp driver. -func IsFrankenPHPProject(dir string) bool { - m := getMedium() - - // Check composer.json for laravel/octane - composerPath := filepath.Join(dir, "composer.json") - data, err := m.Read(composerPath) - if err != nil { - return false - } - - var composer struct { - Require map[string]string `json:"require"` - } - - if err := json.Unmarshal([]byte(data), &composer); err != nil { - return false - } - - if _, ok := composer.Require["laravel/octane"]; !ok { - return false - } - - // Check octane config for frankenphp - configPath := filepath.Join(dir, "config", "octane.php") - if !m.Exists(configPath) { - // If no config exists but octane is installed, assume frankenphp - return true - } - - configData, err := m.Read(configPath) - if err != nil { - return true // Assume frankenphp if we can't read config - } - - // Look for frankenphp in the config - return strings.Contains(configData, "frankenphp") -} - -// DetectServices detects which services are needed based on project files. -func DetectServices(dir string) []DetectedService { - services := []DetectedService{} - - // FrankenPHP/Octane is always needed for a Laravel dev environment - if IsFrankenPHPProject(dir) || IsLaravelProject(dir) { - services = append(services, ServiceFrankenPHP) - } - - // Check for Vite - if hasVite(dir) { - services = append(services, ServiceVite) - } - - // Check for Horizon - if hasHorizon(dir) { - services = append(services, ServiceHorizon) - } - - // Check for Reverb - if hasReverb(dir) { - services = append(services, ServiceReverb) - } - - // Check for Redis - if needsRedis(dir) { - services = append(services, ServiceRedis) - } - - return services -} - -// hasVite checks if the project uses Vite. -func hasVite(dir string) bool { - m := getMedium() - viteConfigs := []string{ - "vite.config.js", - "vite.config.ts", - "vite.config.mjs", - "vite.config.mts", - } - - for _, config := range viteConfigs { - if m.Exists(filepath.Join(dir, config)) { - return true - } - } - - return false -} - -// hasHorizon checks if Laravel Horizon is configured. -func hasHorizon(dir string) bool { - horizonConfig := filepath.Join(dir, "config", "horizon.php") - return getMedium().Exists(horizonConfig) -} - -// hasReverb checks if Laravel Reverb is configured. -func hasReverb(dir string) bool { - reverbConfig := filepath.Join(dir, "config", "reverb.php") - return getMedium().Exists(reverbConfig) -} - -// needsRedis checks if the project uses Redis based on .env configuration. -func needsRedis(dir string) bool { - m := getMedium() - envPath := filepath.Join(dir, ".env") - content, err := m.Read(envPath) - if err != nil { - return false - } - - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "#") { - continue - } - - // Check for Redis-related environment variables - redisIndicators := []string{ - "REDIS_HOST=", - "CACHE_DRIVER=redis", - "QUEUE_CONNECTION=redis", - "SESSION_DRIVER=redis", - "BROADCAST_DRIVER=redis", - } - - for _, indicator := range redisIndicators { - if strings.HasPrefix(line, indicator) { - // Check if it's set to localhost or 127.0.0.1 - if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "localhost") || - indicator != "REDIS_HOST=" { - return true - } - } - } - } - - return false -} - -// DetectPackageManager detects which package manager is used in the project. -// Returns "npm", "pnpm", "yarn", or "bun". -func DetectPackageManager(dir string) string { - m := getMedium() - // Check for lock files in order of preference - lockFiles := []struct { - file string - manager string - }{ - {"bun.lockb", "bun"}, - {"pnpm-lock.yaml", "pnpm"}, - {"yarn.lock", "yarn"}, - {"package-lock.json", "npm"}, - } - - for _, lf := range lockFiles { - if m.Exists(filepath.Join(dir, lf.file)) { - return lf.manager - } - } - - // Default to npm if no lock file found - return "npm" -} - -// GetLaravelAppName extracts the application name from Laravel's .env file. -func GetLaravelAppName(dir string) string { - m := getMedium() - envPath := filepath.Join(dir, ".env") - content, err := m.Read(envPath) - if err != nil { - return "" - } - - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "APP_NAME=") { - value := strings.TrimPrefix(line, "APP_NAME=") - // Remove quotes if present - value = strings.Trim(value, `"'`) - return value - } - } - - return "" -} - -// GetLaravelAppURL extracts the application URL from Laravel's .env file. -func GetLaravelAppURL(dir string) string { - m := getMedium() - envPath := filepath.Join(dir, ".env") - content, err := m.Read(envPath) - if err != nil { - return "" - } - - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "APP_URL=") { - value := strings.TrimPrefix(line, "APP_URL=") - // Remove quotes if present - value = strings.Trim(value, `"'`) - return value - } - } - - return "" -} - -// ExtractDomainFromURL extracts the domain from a URL string. -func ExtractDomainFromURL(url string) string { - // Remove protocol - domain := strings.TrimPrefix(url, "https://") - domain = strings.TrimPrefix(domain, "http://") - - // Remove port if present - if idx := strings.Index(domain, ":"); idx != -1 { - domain = domain[:idx] - } - - // Remove path if present - if idx := strings.Index(domain, "/"); idx != -1 { - domain = domain[:idx] - } - - return domain -} diff --git a/detect_test.go b/detect_test.go deleted file mode 100644 index 9b72f84..0000000 --- a/detect_test.go +++ /dev/null @@ -1,663 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestIsLaravelProject_Good(t *testing.T) { - t.Run("valid Laravel project with artisan and composer.json", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create composer.json with laravel/framework - composerJSON := `{ - "name": "test/laravel-project", - "require": { - "php": "^8.2", - "laravel/framework": "^11.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.True(t, IsLaravelProject(dir)) - }) - - t.Run("Laravel in require-dev", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create composer.json with laravel/framework in require-dev - composerJSON := `{ - "name": "test/laravel-project", - "require-dev": { - "laravel/framework": "^11.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.True(t, IsLaravelProject(dir)) - }) -} - -func TestIsLaravelProject_Bad(t *testing.T) { - t.Run("missing artisan file", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json but no artisan - composerJSON := `{ - "name": "test/laravel-project", - "require": { - "laravel/framework": "^11.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err := os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("missing composer.json", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan but no composer.json - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("composer.json without Laravel", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create composer.json without laravel/framework - composerJSON := `{ - "name": "test/symfony-project", - "require": { - "symfony/framework-bundle": "^7.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("invalid composer.json", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create invalid composer.json - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte("not valid json{"), 0644) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("empty directory", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsLaravelProject("/non/existent/path")) - }) -} - -func TestIsFrankenPHPProject_Good(t *testing.T) { - t.Run("project with octane and frankenphp config", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json with laravel/octane - composerJSON := `{ - "require": { - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Create config directory and octane.php - configDir := filepath.Join(dir, "config") - err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) - - octaneConfig := ` 'frankenphp', -];` - err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) - - assert.True(t, IsFrankenPHPProject(dir)) - }) - - t.Run("project with octane but no config file", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json with laravel/octane - composerJSON := `{ - "require": { - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // No config file - should still return true (assume frankenphp) - assert.True(t, IsFrankenPHPProject(dir)) - }) - - t.Run("project with octane but unreadable config file", func(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root can read any file") - } - dir := t.TempDir() - - // Create composer.json with laravel/octane - composerJSON := `{ - "require": { - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Create config directory and octane.php with no read permissions - configDir := filepath.Join(dir, "config") - err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) - - octanePath := filepath.Join(configDir, "octane.php") - err = os.WriteFile(octanePath, []byte(" 'swoole', -];` - err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) - - assert.False(t, IsFrankenPHPProject(dir)) - }) -} diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app new file mode 100644 index 0000000..a75b3fe --- /dev/null +++ b/docker/Dockerfile.app @@ -0,0 +1,107 @@ +# Host UK — Laravel Application Container +# PHP 8.3-FPM with all extensions required by the federated monorepo +# +# Build: docker build -f docker/Dockerfile.app -t host-uk/app:latest .. +# (run from host-uk/ workspace root, not core/) + +FROM php:8.3-fpm-alpine AS base + +# System dependencies +RUN apk add --no-cache \ + git \ + curl \ + libpng-dev \ + libjpeg-turbo-dev \ + freetype-dev \ + libwebp-dev \ + libzip-dev \ + icu-dev \ + oniguruma-dev \ + libxml2-dev \ + linux-headers \ + $PHPIZE_DEPS + +# PHP extensions +RUN docker-php-ext-configure gd \ + --with-freetype \ + --with-jpeg \ + --with-webp \ + && docker-php-ext-install -j$(nproc) \ + bcmath \ + exif \ + gd \ + intl \ + mbstring \ + opcache \ + pcntl \ + pdo_mysql \ + soap \ + xml \ + zip + +# Redis extension +RUN pecl install redis && docker-php-ext-enable redis + +# Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# PHP configuration +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +COPY docker/php/opcache.ini $PHP_INI_DIR/conf.d/opcache.ini +COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.d/zz-host-uk.conf + +# --- Build stage --- +FROM base AS build + +WORKDIR /app + +# Install dependencies first (cache layer) +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction + +# Copy application +COPY . . + +# Generate autoloader and run post-install +RUN composer dump-autoload --optimize --no-dev \ + && php artisan package:discover --ansi + +# Build frontend assets +RUN if [ -f package.json ]; then \ + apk add --no-cache nodejs npm && \ + npm ci --production=false && \ + npm run build && \ + rm -rf node_modules; \ + fi + +# --- Production stage --- +FROM base AS production + +WORKDIR /app + +# Copy built application +COPY --from=build /app /app + +# Create storage directories +RUN mkdir -p \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache + +# Permissions +RUN chown -R www-data:www-data storage bootstrap/cache + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD php-fpm-healthcheck || exit 1 + +USER www-data + +EXPOSE 9000 diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..e2f76c1 --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,20 @@ +# Host UK — Nginx Web Server +# Serves static files and proxies PHP to FPM container +# +# Build: docker build -f docker/Dockerfile.web -t host-uk/web:latest . + +FROM nginx:1.27-alpine + +# Copy nginx configuration +COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY docker/nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf + +# Copy static assets from app build +# (In production, these are volume-mounted from the app container) +# COPY --from=host-uk/app:latest /app/public /app/public + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost/health || exit 1 + +USER nginx +EXPOSE 80 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 0000000..7f25fa7 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,200 @@ +# Host UK Production Docker Compose +# Deployed to de.host.uk.com and de2.host.uk.com via Coolify +# +# Container topology per app server: +# app - PHP 8.3-FPM (all Laravel modules) +# web - Nginx (static files + FastCGI proxy) +# horizon - Laravel Horizon (queue worker) +# scheduler - Laravel scheduler +# mcp - Go MCP server +# redis - Redis 7 (local cache + sessions) +# galera - MariaDB 11 (Galera cluster node) + +services: + app: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/app:${TAG:-latest} + restart: unless-stopped + volumes: + - app-storage:/app/storage + environment: + - APP_ENV=production + - APP_DEBUG=false + - APP_URL=${APP_URL:-https://host.uk.com} + - DB_HOST=galera + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-hostuk} + - DB_USERNAME=${DB_USERNAME:-hostuk} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + - CACHE_DRIVER=redis + - SESSION_DRIVER=redis + - QUEUE_CONNECTION=redis + depends_on: + redis: + condition: service_healthy + galera: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 + networks: + - app-net + + web: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/web:${TAG:-latest} + restart: unless-stopped + ports: + - "${WEB_PORT:-80}:80" + volumes: + - app-storage:/app/storage:ro + depends_on: + app: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/health"] + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 + networks: + - app-net + + horizon: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/app:${TAG:-latest} + restart: unless-stopped + command: php artisan horizon + volumes: + - app-storage:/app/storage + environment: + - APP_ENV=production + - DB_HOST=galera + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-hostuk} + - DB_USERNAME=${DB_USERNAME:-hostuk} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + depends_on: + app: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php artisan horizon:status | grep -q running"] + interval: 60s + timeout: 5s + start_period: 30s + retries: 3 + networks: + - app-net + + scheduler: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/app:${TAG:-latest} + restart: unless-stopped + command: php artisan schedule:work + volumes: + - app-storage:/app/storage + environment: + - APP_ENV=production + - DB_HOST=galera + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-hostuk} + - DB_USERNAME=${DB_USERNAME:-hostuk} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=6379 + depends_on: + app: + condition: service_healthy + networks: + - app-net + + mcp: + image: ${REGISTRY:-gitea.snider.dev}/host-uk/core:${TAG:-latest} + restart: unless-stopped + command: core mcp serve + ports: + - "${MCP_PORT:-9001}:9000" + environment: + - MCP_ADDR=:9000 + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 9000 || exit 1"] + interval: 30s + timeout: 3s + retries: 3 + networks: + - app-net + + redis: + image: redis:7-alpine + restart: unless-stopped + command: > + redis-server + --maxmemory 512mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - app-net + + galera: + image: mariadb:11 + restart: unless-stopped + environment: + - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} + - MARIADB_DATABASE=${DB_DATABASE:-hostuk} + - MARIADB_USER=${DB_USERNAME:-hostuk} + - MARIADB_PASSWORD=${DB_PASSWORD} + - WSREP_CLUSTER_NAME=hostuk-galera + - WSREP_CLUSTER_ADDRESS=${GALERA_CLUSTER_ADDRESS:-gcomm://} + - WSREP_NODE_ADDRESS=${GALERA_NODE_ADDRESS} + - WSREP_NODE_NAME=${GALERA_NODE_NAME} + - WSREP_SST_METHOD=mariabackup + command: > + --wsrep-on=ON + --wsrep-provider=/usr/lib/galera/libgalera_smm.so + --wsrep-cluster-name=hostuk-galera + --wsrep-cluster-address=${GALERA_CLUSTER_ADDRESS:-gcomm://} + --wsrep-node-address=${GALERA_NODE_ADDRESS} + --wsrep-node-name=${GALERA_NODE_NAME} + --wsrep-sst-method=mariabackup + --binlog-format=ROW + --default-storage-engine=InnoDB + --innodb-autoinc-lock-mode=2 + --innodb-buffer-pool-size=1G + --innodb-log-file-size=256M + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + volumes: + - galera-data:/var/lib/mysql + ports: + - "${GALERA_PORT:-3306}:3306" + - "4567:4567" + - "4568:4568" + - "4444:4444" + healthcheck: + test: ["CMD-SHELL", "mariadb -u root -p${DB_ROOT_PASSWORD} -e 'SHOW STATUS LIKE \"wsrep_ready\"' | grep -q ON"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 5 + networks: + - app-net + +volumes: + app-storage: + redis-data: + galera-data: + +networks: + app-net: + driver: bridge diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..b05018e --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,59 @@ +# Host UK Nginx Configuration +# Proxies PHP to the app (FPM) container, serves static files directly + +server { + listen 80; + server_name _; + + root /app/public; + index index.php; + + charset utf-8; + + # Security headers + include /etc/nginx/snippets/security-headers.conf; + + # Health check endpoint (no logging) + location = /health { + access_log off; + try_files $uri /index.php?$query_string; + } + + # Static file caching + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + # Laravel application + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # PHP-FPM upstream + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + + fastcgi_hide_header X-Powered-By; + fastcgi_buffer_size 32k; + fastcgi_buffers 16 16k; + fastcgi_read_timeout 300; + + # Pass real client IP from LB proxy protocol + fastcgi_param REMOTE_ADDR $http_x_forwarded_for; + } + + # Block dotfiles (except .well-known) + location ~ /\.(?!well-known) { + deny all; + } + + # Block access to sensitive files + location ~* \.(env|log|yaml|yml|toml|lock|bak|sql)$ { + deny all; + } +} diff --git a/docker/nginx/security-headers.conf b/docker/nginx/security-headers.conf new file mode 100644 index 0000000..3917d7a --- /dev/null +++ b/docker/nginx/security-headers.conf @@ -0,0 +1,6 @@ +# Security headers for Host UK +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-XSS-Protection "1; mode=block" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; diff --git a/docker/php/opcache.ini b/docker/php/opcache.ini new file mode 100644 index 0000000..61a65c1 --- /dev/null +++ b/docker/php/opcache.ini @@ -0,0 +1,10 @@ +; OPcache configuration for production +opcache.enable=1 +opcache.memory_consumption=256 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=20000 +opcache.validate_timestamps=0 +opcache.save_comments=1 +opcache.fast_shutdown=1 +opcache.jit_buffer_size=128M +opcache.jit=1255 diff --git a/docker/php/php-fpm.conf b/docker/php/php-fpm.conf new file mode 100644 index 0000000..c19e21c --- /dev/null +++ b/docker/php/php-fpm.conf @@ -0,0 +1,22 @@ +; Host UK PHP-FPM pool configuration +[www] +pm = dynamic +pm.max_children = 50 +pm.start_servers = 10 +pm.min_spare_servers = 5 +pm.max_spare_servers = 20 +pm.max_requests = 1000 +pm.process_idle_timeout = 10s + +; Status page for health checks +pm.status_path = /fpm-status +ping.path = /fpm-ping +ping.response = pong + +; Logging +access.log = /proc/self/fd/2 +slowlog = /proc/self/fd/2 +request_slowlog_timeout = 5s + +; Security +security.limit_extensions = .php diff --git a/dockerfile.go b/dockerfile.go deleted file mode 100644 index be7afd1..0000000 --- a/dockerfile.go +++ /dev/null @@ -1,398 +0,0 @@ -package php - -import ( - "encoding/json" - "path/filepath" - "sort" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// DockerfileConfig holds configuration for generating a Dockerfile. -type DockerfileConfig struct { - // PHPVersion is the PHP version to use (default: "8.3"). - PHPVersion string - - // BaseImage is the base Docker image (default: "dunglas/frankenphp"). - BaseImage string - - // PHPExtensions is the list of PHP extensions to install. - PHPExtensions []string - - // HasAssets indicates if the project has frontend assets to build. - HasAssets bool - - // PackageManager is the Node.js package manager (npm, pnpm, yarn, bun). - PackageManager string - - // IsLaravel indicates if this is a Laravel project. - IsLaravel bool - - // HasOctane indicates if Laravel Octane is installed. - HasOctane bool - - // UseAlpine uses the Alpine-based image (smaller). - UseAlpine bool -} - -// GenerateDockerfile generates a Dockerfile for a PHP/Laravel project. -// It auto-detects dependencies from composer.json and project structure. -func GenerateDockerfile(dir string) (string, error) { - config, err := DetectDockerfileConfig(dir) - if err != nil { - return "", err - } - - return GenerateDockerfileFromConfig(config), nil -} - -// DetectDockerfileConfig detects configuration from project files. -func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) { - m := getMedium() - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - } - - // Read composer.json - composerPath := filepath.Join(dir, "composer.json") - composerContent, err := m.Read(composerPath) - if err != nil { - return nil, cli.WrapVerb(err, "read", "composer.json") - } - - var composer ComposerJSON - if err := json.Unmarshal([]byte(composerContent), &composer); err != nil { - return nil, cli.WrapVerb(err, "parse", "composer.json") - } - - // Detect PHP version from composer.json - if phpVersion, ok := composer.Require["php"]; ok { - config.PHPVersion = extractPHPVersion(phpVersion) - } - - // Detect if Laravel - if _, ok := composer.Require["laravel/framework"]; ok { - config.IsLaravel = true - } - - // Detect if Octane - if _, ok := composer.Require["laravel/octane"]; ok { - config.HasOctane = true - } - - // Detect required PHP extensions - config.PHPExtensions = detectPHPExtensions(composer) - - // Detect frontend assets - config.HasAssets = hasNodeAssets(dir) - if config.HasAssets { - config.PackageManager = DetectPackageManager(dir) - } - - return config, nil -} - -// GenerateDockerfileFromConfig generates a Dockerfile from the given configuration. -func GenerateDockerfileFromConfig(config *DockerfileConfig) string { - var sb strings.Builder - - // Base image - baseTag := cli.Sprintf("latest-php%s", config.PHPVersion) - if config.UseAlpine { - baseTag += "-alpine" - } - - sb.WriteString("# Auto-generated Dockerfile for FrankenPHP\n") - sb.WriteString("# Generated by Core Framework\n\n") - - // Multi-stage build for smaller images - if config.HasAssets { - // Frontend build stage - sb.WriteString("# Stage 1: Build frontend assets\n") - sb.WriteString("FROM node:20-alpine AS frontend\n\n") - sb.WriteString("WORKDIR /app\n\n") - - // Copy package files based on package manager - switch config.PackageManager { - case "pnpm": - sb.WriteString("RUN corepack enable && corepack prepare pnpm@latest --activate\n\n") - sb.WriteString("COPY package.json pnpm-lock.yaml ./\n") - sb.WriteString("RUN pnpm install --frozen-lockfile\n\n") - case "yarn": - sb.WriteString("COPY package.json yarn.lock ./\n") - sb.WriteString("RUN yarn install --frozen-lockfile\n\n") - case "bun": - sb.WriteString("RUN npm install -g bun\n\n") - sb.WriteString("COPY package.json bun.lockb ./\n") - sb.WriteString("RUN bun install --frozen-lockfile\n\n") - default: // npm - sb.WriteString("COPY package.json package-lock.json ./\n") - sb.WriteString("RUN npm ci\n\n") - } - - sb.WriteString("COPY . .\n\n") - - // Build command - switch config.PackageManager { - case "pnpm": - sb.WriteString("RUN pnpm run build\n\n") - case "yarn": - sb.WriteString("RUN yarn build\n\n") - case "bun": - sb.WriteString("RUN bun run build\n\n") - default: - sb.WriteString("RUN npm run build\n\n") - } - } - - // PHP build stage - stageNum := 2 - if config.HasAssets { - sb.WriteString(cli.Sprintf("# Stage %d: PHP application\n", stageNum)) - } - sb.WriteString(cli.Sprintf("FROM %s:%s AS app\n\n", config.BaseImage, baseTag)) - - sb.WriteString("WORKDIR /app\n\n") - - // Install PHP extensions if needed - if len(config.PHPExtensions) > 0 { - sb.WriteString("# Install PHP extensions\n") - sb.WriteString(cli.Sprintf("RUN install-php-extensions %s\n\n", strings.Join(config.PHPExtensions, " "))) - } - - // Copy composer files first for better caching - sb.WriteString("# Copy composer files\n") - sb.WriteString("COPY composer.json composer.lock ./\n\n") - - // Install composer dependencies - sb.WriteString("# Install PHP dependencies\n") - sb.WriteString("RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction\n\n") - - // Copy application code - sb.WriteString("# Copy application code\n") - sb.WriteString("COPY . .\n\n") - - // Run post-install scripts - sb.WriteString("# Run composer scripts\n") - sb.WriteString("RUN composer dump-autoload --optimize\n\n") - - // Copy frontend assets if built - if config.HasAssets { - sb.WriteString("# Copy built frontend assets\n") - sb.WriteString("COPY --from=frontend /app/public/build public/build\n\n") - } - - // Laravel-specific setup - if config.IsLaravel { - sb.WriteString("# Laravel setup\n") - sb.WriteString("RUN php artisan config:cache \\\n") - sb.WriteString(" && php artisan route:cache \\\n") - sb.WriteString(" && php artisan view:cache\n\n") - - // Set permissions - sb.WriteString("# Set permissions for Laravel\n") - sb.WriteString("RUN chown -R www-data:www-data storage bootstrap/cache \\\n") - sb.WriteString(" && chmod -R 775 storage bootstrap/cache\n\n") - } - - // Expose ports - sb.WriteString("# Expose ports\n") - sb.WriteString("EXPOSE 80 443\n\n") - - // Health check - sb.WriteString("# Health check\n") - sb.WriteString("HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n") - sb.WriteString(" CMD curl -f http://localhost/up || exit 1\n\n") - - // Start command - sb.WriteString("# Start FrankenPHP\n") - if config.HasOctane { - sb.WriteString("CMD [\"php\", \"artisan\", \"octane:start\", \"--server=frankenphp\", \"--host=0.0.0.0\", \"--port=80\"]\n") - } else { - sb.WriteString("CMD [\"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\"]\n") - } - - return sb.String() -} - -// ComposerJSON represents the structure of composer.json. -type ComposerJSON struct { - Name string `json:"name"` - Require map[string]string `json:"require"` - RequireDev map[string]string `json:"require-dev"` -} - -// detectPHPExtensions detects required PHP extensions from composer.json. -func detectPHPExtensions(composer ComposerJSON) []string { - extensionMap := make(map[string]bool) - - // Check for common packages and their required extensions - packageExtensions := map[string][]string{ - // Database - "doctrine/dbal": {"pdo_mysql", "pdo_pgsql"}, - "illuminate/database": {"pdo_mysql"}, - "laravel/framework": {"pdo_mysql", "bcmath", "ctype", "fileinfo", "mbstring", "openssl", "tokenizer", "xml"}, - "mongodb/mongodb": {"mongodb"}, - "predis/predis": {"redis"}, - "phpredis/phpredis": {"redis"}, - "laravel/horizon": {"redis", "pcntl"}, - "aws/aws-sdk-php": {"curl"}, - "intervention/image": {"gd"}, - "intervention/image-laravel": {"gd"}, - "spatie/image": {"gd"}, - "league/flysystem-aws-s3-v3": {"curl"}, - "guzzlehttp/guzzle": {"curl"}, - "nelmio/cors-bundle": {}, - // Queues - "laravel/reverb": {"pcntl"}, - "php-amqplib/php-amqplib": {"sockets"}, - // Misc - "moneyphp/money": {"bcmath", "intl"}, - "symfony/intl": {"intl"}, - "nesbot/carbon": {"intl"}, - "spatie/laravel-medialibrary": {"exif", "gd"}, - } - - // Check all require and require-dev dependencies - allDeps := make(map[string]string) - for pkg, ver := range composer.Require { - allDeps[pkg] = ver - } - for pkg, ver := range composer.RequireDev { - allDeps[pkg] = ver - } - - // Find required extensions - for pkg := range allDeps { - if exts, ok := packageExtensions[pkg]; ok { - for _, ext := range exts { - extensionMap[ext] = true - } - } - - // Check for direct ext- requirements - if strings.HasPrefix(pkg, "ext-") { - ext := strings.TrimPrefix(pkg, "ext-") - // Skip extensions that are built into PHP - builtIn := map[string]bool{ - "json": true, "ctype": true, "iconv": true, - "session": true, "simplexml": true, "pdo": true, - "xml": true, "tokenizer": true, - } - if !builtIn[ext] { - extensionMap[ext] = true - } - } - } - - // Convert to sorted slice - extensions := make([]string, 0, len(extensionMap)) - for ext := range extensionMap { - extensions = append(extensions, ext) - } - sort.Strings(extensions) - - return extensions -} - -// extractPHPVersion extracts a clean PHP version from a composer constraint. -func extractPHPVersion(constraint string) string { - // Handle common formats: ^8.2, >=8.2, 8.2.*, ~8.2 - constraint = strings.TrimLeft(constraint, "^>=~") - constraint = strings.TrimRight(constraint, ".*") - - // Extract major.minor - parts := strings.Split(constraint, ".") - if len(parts) >= 2 { - return parts[0] + "." + parts[1] - } - if len(parts) == 1 { - return parts[0] + ".0" - } - - return "8.3" // default -} - -// hasNodeAssets checks if the project has frontend assets. -func hasNodeAssets(dir string) bool { - m := getMedium() - packageJSON := filepath.Join(dir, "package.json") - if !m.IsFile(packageJSON) { - return false - } - - // Check for build script in package.json - content, err := m.Read(packageJSON) - if err != nil { - return false - } - - var pkg struct { - Scripts map[string]string `json:"scripts"` - } - - if err := json.Unmarshal([]byte(content), &pkg); err != nil { - return false - } - - // Check if there's a build script - _, hasBuild := pkg.Scripts["build"] - return hasBuild -} - -// GenerateDockerignore generates a .dockerignore file content for PHP projects. -func GenerateDockerignore(dir string) string { - var sb strings.Builder - - sb.WriteString("# Git\n") - sb.WriteString(".git\n") - sb.WriteString(".gitignore\n") - sb.WriteString(".gitattributes\n\n") - - sb.WriteString("# Node\n") - sb.WriteString("node_modules\n\n") - - sb.WriteString("# Development\n") - sb.WriteString(".env\n") - sb.WriteString(".env.local\n") - sb.WriteString(".env.*.local\n") - sb.WriteString("*.log\n") - sb.WriteString(".phpunit.result.cache\n") - sb.WriteString("phpunit.xml\n") - sb.WriteString(".php-cs-fixer.cache\n") - sb.WriteString("phpstan.neon\n\n") - - sb.WriteString("# IDE\n") - sb.WriteString(".idea\n") - sb.WriteString(".vscode\n") - sb.WriteString("*.swp\n") - sb.WriteString("*.swo\n\n") - - sb.WriteString("# Laravel specific\n") - sb.WriteString("storage/app/*\n") - sb.WriteString("storage/logs/*\n") - sb.WriteString("storage/framework/cache/*\n") - sb.WriteString("storage/framework/sessions/*\n") - sb.WriteString("storage/framework/views/*\n") - sb.WriteString("bootstrap/cache/*\n\n") - - sb.WriteString("# Build artifacts\n") - sb.WriteString("public/hot\n") - sb.WriteString("public/storage\n") - sb.WriteString("vendor\n\n") - - sb.WriteString("# Docker\n") - sb.WriteString("Dockerfile*\n") - sb.WriteString("docker-compose*.yml\n") - sb.WriteString(".dockerignore\n\n") - - sb.WriteString("# Documentation\n") - sb.WriteString("README.md\n") - sb.WriteString("CHANGELOG.md\n") - sb.WriteString("docs\n") - - return sb.String() -} diff --git a/dockerfile_test.go b/dockerfile_test.go deleted file mode 100644 index 5c3b1ce..0000000 --- a/dockerfile_test.go +++ /dev/null @@ -1,634 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGenerateDockerfile_Good(t *testing.T) { - t.Run("basic Laravel project", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json - composerJSON := `{ - "name": "test/laravel-project", - "require": { - "php": "^8.2", - "laravel/framework": "^11.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Create composer.lock - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - // Check content - assert.Contains(t, content, "FROM dunglas/frankenphp") - assert.Contains(t, content, "php8.2") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "composer install") - assert.Contains(t, content, "EXPOSE 80 443") - }) - - t.Run("Laravel project with Octane", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-octane", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "php8.3") - assert.Contains(t, content, "octane:start") - }) - - t.Run("project with frontend assets", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-vite", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - packageJSON := `{ - "name": "test-app", - "scripts": { - "dev": "vite", - "build": "vite build" - } - }` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - // Should have multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "npm ci") - assert.Contains(t, content, "npm run build") - assert.Contains(t, content, "COPY --from=frontend") - }) - - t.Run("project with pnpm", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-pnpm", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - packageJSON := `{ - "name": "test-app", - "scripts": { - "build": "vite build" - } - }` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - - // Create pnpm-lock.yaml - err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "pnpm install") - assert.Contains(t, content, "pnpm run build") - }) - - t.Run("project with Redis dependency", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-redis", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "predis/predis": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "redis") - }) - - t.Run("project with explicit ext- requirements", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/with-extensions", - "require": { - "php": "^8.3", - "ext-gd": "*", - "ext-imagick": "*", - "ext-intl": "*" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "gd") - assert.Contains(t, content, "imagick") - assert.Contains(t, content, "intl") - }) -} - -func TestGenerateDockerfile_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { - dir := t.TempDir() - - _, err := GenerateDockerfile(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "composer.json") - }) - - t.Run("invalid composer.json", func(t *testing.T) { - dir := t.TempDir() - - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) - - _, err = GenerateDockerfile(dir) - assert.Error(t, err) - }) -} - -func TestDetectDockerfileConfig_Good(t *testing.T) { - t.Run("full Laravel project", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/full-laravel", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "laravel/octane": "^2.0", - "predis/predis": "^2.0", - "intervention/image": "^3.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - packageJSON := `{"scripts": {"build": "vite build"}}` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) - require.NoError(t, err) - - config, err := DetectDockerfileConfig(dir) - require.NoError(t, err) - - assert.Equal(t, "8.3", config.PHPVersion) - assert.True(t, config.IsLaravel) - assert.True(t, config.HasOctane) - assert.True(t, config.HasAssets) - assert.Equal(t, "yarn", config.PackageManager) - assert.Contains(t, config.PHPExtensions, "redis") - assert.Contains(t, config.PHPExtensions, "gd") - }) -} - -func TestDetectDockerfileConfig_Bad(t *testing.T) { - t.Run("non-existent directory", func(t *testing.T) { - _, err := DetectDockerfileConfig("/non/existent/path") - assert.Error(t, err) - }) -} - -func TestExtractPHPVersion_Good(t *testing.T) { - tests := []struct { - constraint string - expected string - }{ - {"^8.2", "8.2"}, - {"^8.3", "8.3"}, - {">=8.2", "8.2"}, - {"~8.2", "8.2"}, - {"8.2.*", "8.2"}, - {"8.2.0", "8.2"}, - {"8", "8.0"}, - } - - for _, tt := range tests { - t.Run(tt.constraint, func(t *testing.T) { - result := extractPHPVersion(tt.constraint) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestDetectPHPExtensions_Good(t *testing.T) { - t.Run("detects Redis from predis", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "predis/predis": "^2.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") - }) - - t.Run("detects GD from intervention/image", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "intervention/image": "^3.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") - }) - - t.Run("detects multiple extensions from Laravel", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "laravel/framework": "^11.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "pdo_mysql") - assert.Contains(t, extensions, "bcmath") - }) - - t.Run("detects explicit ext- requirements", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "ext-gd": "*", - "ext-imagick": "*", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") - assert.Contains(t, extensions, "imagick") - }) - - t.Run("skips built-in extensions", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "ext-json": "*", - "ext-session": "*", - "ext-pdo": "*", - }, - } - - extensions := detectPHPExtensions(composer) - assert.NotContains(t, extensions, "json") - assert.NotContains(t, extensions, "session") - assert.NotContains(t, extensions, "pdo") - }) - - t.Run("sorts extensions alphabetically", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "ext-zip": "*", - "ext-gd": "*", - "ext-intl": "*", - }, - } - - extensions := detectPHPExtensions(composer) - - // Check they are sorted - for i := 1; i < len(extensions); i++ { - assert.True(t, extensions[i-1] < extensions[i], - "extensions should be sorted: %v", extensions) - } - }) -} - -func TestHasNodeAssets_Good(t *testing.T) { - t.Run("with build script", func(t *testing.T) { - dir := t.TempDir() - - packageJSON := `{ - "name": "test", - "scripts": { - "dev": "vite", - "build": "vite build" - } - }` - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - - assert.True(t, hasNodeAssets(dir)) - }) -} - -func TestHasNodeAssets_Bad(t *testing.T) { - t.Run("no package.json", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, hasNodeAssets(dir)) - }) - - t.Run("no build script", func(t *testing.T) { - dir := t.TempDir() - - packageJSON := `{ - "name": "test", - "scripts": { - "dev": "vite" - } - }` - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - - assert.False(t, hasNodeAssets(dir)) - }) - - t.Run("invalid package.json", func(t *testing.T) { - dir := t.TempDir() - - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644) - require.NoError(t, err) - - assert.False(t, hasNodeAssets(dir)) - }) -} - -func TestGenerateDockerignore_Good(t *testing.T) { - t.Run("generates complete dockerignore", func(t *testing.T) { - dir := t.TempDir() - content := GenerateDockerignore(dir) - - // Check key entries - assert.Contains(t, content, ".git") - assert.Contains(t, content, "node_modules") - assert.Contains(t, content, ".env") - assert.Contains(t, content, "vendor") - assert.Contains(t, content, "storage/logs/*") - assert.Contains(t, content, ".idea") - assert.Contains(t, content, ".vscode") - }) -} - -func TestGenerateDockerfileFromConfig_Good(t *testing.T) { - t.Run("minimal config", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine") - assert.Contains(t, content, "WORKDIR /app") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "EXPOSE 80 443") - }) - - t.Run("with extensions", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - PHPExtensions: []string{"redis", "gd", "intl"}, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "install-php-extensions redis gd intl") - }) - - t.Run("Laravel with Octane", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - IsLaravel: true, - HasOctane: true, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "php artisan config:cache") - assert.Contains(t, content, "php artisan route:cache") - assert.Contains(t, content, "php artisan view:cache") - assert.Contains(t, content, "chown -R www-data:www-data storage") - assert.Contains(t, content, "octane:start") - }) - - t.Run("with frontend assets", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - HasAssets: true, - PackageManager: "npm", - } - - content := GenerateDockerfileFromConfig(config) - - // Multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "COPY package.json package-lock.json") - assert.Contains(t, content, "RUN npm ci") - assert.Contains(t, content, "RUN npm run build") - assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build") - }) - - t.Run("with yarn", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - HasAssets: true, - PackageManager: "yarn", - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "COPY package.json yarn.lock") - assert.Contains(t, content, "yarn install --frozen-lockfile") - assert.Contains(t, content, "yarn build") - }) - - t.Run("with bun", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - HasAssets: true, - PackageManager: "bun", - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "npm install -g bun") - assert.Contains(t, content, "COPY package.json bun.lockb") - assert.Contains(t, content, "bun install --frozen-lockfile") - assert.Contains(t, content, "bun run build") - }) - - t.Run("non-alpine image", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: false, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app") - assert.NotContains(t, content, "alpine") - }) -} - -func TestIsPHPProject_Good(t *testing.T) { - t.Run("project with composer.json", func(t *testing.T) { - dir := t.TempDir() - - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644) - require.NoError(t, err) - - assert.True(t, IsPHPProject(dir)) - }) -} - -func TestIsPHPProject_Bad(t *testing.T) { - t.Run("project without composer.json", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) - }) - - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) - }) -} - -func TestExtractPHPVersion_Edge(t *testing.T) { - t.Run("handles single major version", func(t *testing.T) { - result := extractPHPVersion("8") - assert.Equal(t, "8.0", result) - }) -} - -func TestDetectPHPExtensions_RequireDev(t *testing.T) { - t.Run("detects extensions from require-dev", func(t *testing.T) { - composer := ComposerJSON{ - RequireDev: map[string]string{ - "predis/predis": "^2.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") - }) -} - -func TestDockerfileStructure_Good(t *testing.T) { - t.Run("Dockerfile has proper structure", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/app", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "laravel/octane": "^2.0", - "predis/predis": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - packageJSON := `{"scripts": {"build": "vite build"}}` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - lines := strings.Split(content, "\n") - var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - switch { - case strings.HasPrefix(trimmed, "FROM "): - fromCount++ - case strings.HasPrefix(trimmed, "WORKDIR "): - workdirCount++ - case strings.HasPrefix(trimmed, "COPY "): - copyCount++ - case strings.HasPrefix(trimmed, "RUN "): - runCount++ - case strings.HasPrefix(trimmed, "EXPOSE "): - exposeCount++ - case strings.HasPrefix(trimmed, "CMD ["): - // Only count actual CMD instructions, not HEALTHCHECK CMD - cmdCount++ - } - } - - // Multi-stage build should have 2 FROM statements - assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build") - - // Should have proper structure - assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR") - assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements") - assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements") - assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE") - assert.Equal(t, 1, cmdCount, "should have exactly one CMD") - }) -} diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js new file mode 100644 index 0000000..8cd6715 --- /dev/null +++ b/docs/.vitepress/config.js @@ -0,0 +1,170 @@ +import { defineConfig } from 'vitepress' +import { fileURLToPath } from 'url' +import path from 'path' +import { getPackagesSidebar, getPackagesNav } from './sidebar.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const docsDir = path.resolve(__dirname, '..') + +// Auto-discover packages and build items +const packagesSidebar = getPackagesSidebar(docsDir) +const packagesNav = getPackagesNav(docsDir) + +export default defineConfig({ + title: 'Host UK', + description: 'Native application frameworks for PHP and Go', + base: '/', + + ignoreDeadLinks: [ + // Ignore localhost links + /^https?:\/\/localhost/, + // Old paths during migration + /\/packages\/core/, + /\/packages\/(php|go)/, + /\/core\//, + /\/architecture\//, + /\/patterns-guide\//, + // Security pages moved to /build/php/ + /\/security\//, + // Package pages not yet created + /\/packages\/admin\/(tables|security|hlcrf|activity)/, + /\/packages\/api\/(openapi|analytics|alerts|logging)/, + /\/packages\/mcp\/commerce/, + /\/build\/php\/(services|seeders|security|email-shield|action-gate|i18n)/, + // Package root links (without trailing slash) - VitePress resolves these + /^\/packages\/(admin|api|mcp|tenant|commerce|content|developer)$/, + /^\/packages\/(admin|api|mcp|tenant|commerce|content|developer)#/, + /^\/build\/(php|go)$/, + /^\/build\/(php|go)#/, + // Guide moved to /build/php/ + /\/guide\//, + // Other pages not yet created + /\/testing\//, + /\/changelog/, + /\/contributing/, + // Go docs - relative paths (cmd moved to /build/cli/) + /\.\.\/configuration/, + /\.\.\/examples/, + /\.\/cmd\//, + ], + + themeConfig: { + logo: '/logo.svg', + + nav: [ + { + text: 'Build', + activeMatch: '/build/', + items: [ + { text: 'PHP', link: '/build/php/' }, + { text: 'Go', link: '/build/go/' }, + { text: 'CLI', link: '/build/cli/' } + ] + }, + { + text: 'Publish', + activeMatch: '/publish/', + items: [ + { text: 'Overview', link: '/publish/' }, + { text: 'GitHub', link: '/publish/github' }, + { text: 'Docker', link: '/publish/docker' }, + { text: 'npm', link: '/publish/npm' }, + { text: 'Homebrew', link: '/publish/homebrew' }, + { text: 'Scoop', link: '/publish/scoop' }, + { text: 'AUR', link: '/publish/aur' }, + { text: 'Chocolatey', link: '/publish/chocolatey' }, + { text: 'LinuxKit', link: '/publish/linuxkit' } + ] + }, + { + text: 'Deploy', + activeMatch: '/deploy/', + items: [ + { text: 'Overview', link: '/deploy/' }, + { text: 'PHP', link: '/deploy/php' }, + { text: 'LinuxKit VMs', link: '/deploy/linuxkit' }, + { text: 'Templates', link: '/deploy/templates' }, + { text: 'Docker', link: '/deploy/docker' } + ] + }, + { + text: 'Packages', + items: packagesNav + } + ], + + sidebar: { + // Packages index + '/packages/': [ + { + text: 'Packages', + items: packagesNav.map(p => ({ text: p.text, link: p.link })) + } + ], + + // Publish section + '/publish/': [ + { + text: 'Publish', + items: [ + { text: 'Overview', link: '/publish/' }, + { text: 'GitHub', link: '/publish/github' }, + { text: 'Docker', link: '/publish/docker' }, + { text: 'npm', link: '/publish/npm' }, + { text: 'Homebrew', link: '/publish/homebrew' }, + { text: 'Scoop', link: '/publish/scoop' }, + { text: 'AUR', link: '/publish/aur' }, + { text: 'Chocolatey', link: '/publish/chocolatey' }, + { text: 'LinuxKit', link: '/publish/linuxkit' } + ] + } + ], + + // Deploy section + '/deploy/': [ + { + text: 'Deploy', + items: [ + { text: 'Overview', link: '/deploy/' }, + { text: 'PHP', link: '/deploy/php' }, + { text: 'LinuxKit VMs', link: '/deploy/linuxkit' }, + { text: 'Templates', link: '/deploy/templates' }, + { text: 'Docker', link: '/deploy/docker' } + ] + } + ], + + // Auto-discovered package sidebars (php, go, admin, api, mcp, etc.) + ...packagesSidebar, + + '/api/': [ + { + text: 'API Reference', + items: [ + { text: 'Authentication', link: '/api/authentication' }, + { text: 'Endpoints', link: '/api/endpoints' }, + { text: 'Errors', link: '/api/errors' } + ] + } + ] + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/host-uk' } + ], + + footer: { + message: 'Released under the EUPL-1.2 License.', + copyright: 'Copyright © 2024-present Host UK' + }, + + search: { + provider: 'local' + }, + + editLink: { + pattern: 'https://github.com/host-uk/core-php/edit/main/docs/:path', + text: 'Edit this page on GitHub' + } + } +}) diff --git a/docs/.vitepress/sidebar.js b/docs/.vitepress/sidebar.js new file mode 100644 index 0000000..843c0f5 --- /dev/null +++ b/docs/.vitepress/sidebar.js @@ -0,0 +1,187 @@ +import fs from 'fs' +import path from 'path' +import matter from 'gray-matter' + +// Auto-discover packages from docs/packages/ and docs/build/ +// Each package folder should have an index.md +// +// Frontmatter options: +// title: "Page Title" - Used in sidebar +// sidebarTitle: "Short Title" - Override for sidebar (optional) +// order: 10 - Sort order (lower = first) +// collapsed: true - Start group collapsed (for directories) + +export function getPackagesSidebar(docsDir) { + return { + ...getSidebarForDir(docsDir, 'packages'), + ...getSidebarForDir(docsDir, 'build'), + ...getSidebarForDir(docsDir, 'publish'), + ...getSidebarForDir(docsDir, 'deploy') + } +} + +function getSidebarForDir(docsDir, dirName) { + const targetDir = path.join(docsDir, dirName) + + if (!fs.existsSync(targetDir)) { + return {} + } + + const sidebar = {} + const packages = fs.readdirSync(targetDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + .sort() + + for (const pkg of packages) { + const pkgDir = path.join(targetDir, pkg) + + // Build sidebar tree recursively + const items = buildSidebarItems(pkgDir, `/${dirName}/${pkg}`) + + if (items.length === 0) continue + + // Get package title from index.md + let packageTitle = formatTitle(pkg) + const indexPath = path.join(pkgDir, 'index.md') + if (fs.existsSync(indexPath)) { + const content = fs.readFileSync(indexPath, 'utf-8') + const { data } = matter(content) + if (data.title) { + packageTitle = data.title + } else { + const h1Match = content.match(/^#\s+(.+)$/m) + if (h1Match) packageTitle = h1Match[1] + } + } + + sidebar[`/${dirName}/${pkg}/`] = [ + { + text: packageTitle, + items: items + } + ] + } + + return sidebar +} + +// Recursively build sidebar items for a directory +function buildSidebarItems(dir, urlBase) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + const items = [] + + // Process files first, then directories + const files = entries.filter(e => !e.isDirectory() && e.name.endsWith('.md')) + const dirs = entries.filter(e => e.isDirectory()) + + // Add markdown files + for (const file of files) { + const filePath = path.join(dir, file.name) + const content = fs.readFileSync(filePath, 'utf-8') + const { data } = matter(content) + + let title = data.sidebarTitle || data.title + if (!title) { + const h1Match = content.match(/^#\s+(.+)$/m) + title = h1Match ? h1Match[1] : formatTitle(file.name.replace('.md', '')) + } + + const isIndex = file.name === 'index.md' + items.push({ + file: file.name, + text: isIndex ? 'Overview' : title, + link: isIndex ? `${urlBase}/` : `${urlBase}/${file.name.replace('.md', '')}`, + order: data.order ?? (isIndex ? -1 : 100) + }) + } + + // Add subdirectories as collapsed groups + for (const subdir of dirs) { + const subdirPath = path.join(dir, subdir.name) + const subdirUrl = `${urlBase}/${subdir.name}` + const subItems = buildSidebarItems(subdirPath, subdirUrl) + + if (subItems.length === 0) continue + + // Check for index.md in subdir for title/order + let groupTitle = formatTitle(subdir.name) + let groupOrder = 200 + let collapsed = true + + const indexPath = path.join(subdirPath, 'index.md') + if (fs.existsSync(indexPath)) { + const content = fs.readFileSync(indexPath, 'utf-8') + const { data } = matter(content) + if (data.sidebarTitle || data.title) { + groupTitle = data.sidebarTitle || data.title + } else { + const h1Match = content.match(/^#\s+(.+)$/m) + if (h1Match) groupTitle = h1Match[1] + } + if (data.order !== undefined) groupOrder = data.order + if (data.collapsed !== undefined) collapsed = data.collapsed + } + + items.push({ + text: groupTitle, + collapsed: collapsed, + items: subItems, + order: groupOrder + }) + } + + // Sort by order, then alphabetically + items.sort((a, b) => { + const orderA = a.order ?? 100 + const orderB = b.order ?? 100 + if (orderA !== orderB) return orderA - orderB + return a.text.localeCompare(b.text) + }) + + // Remove order from final output + return items.map(({ order, file, ...item }) => item) +} + +// Get nav items for packages dropdown +export function getPackagesNav(docsDir) { + const packagesDir = path.join(docsDir, 'packages') + + if (!fs.existsSync(packagesDir)) { + return [] + } + + return fs.readdirSync(packagesDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .filter(d => fs.existsSync(path.join(packagesDir, d.name, 'index.md'))) + .map(d => { + const indexPath = path.join(packagesDir, d.name, 'index.md') + const content = fs.readFileSync(indexPath, 'utf-8') + const { data } = matter(content) + + let title = data.navTitle || data.title + if (!title) { + const h1Match = content.match(/^#\s+(.+)$/m) + title = h1Match ? h1Match[1] : formatTitle(d.name) + } + + return { + text: title, + link: `/packages/${d.name}/`, + order: data.navOrder ?? 100 + } + }) + .sort((a, b) => { + if (a.order !== b.order) return a.order - b.order + return a.text.localeCompare(b.text) + }) + .map(({ text, link }) => ({ text, link })) +} + +// Convert kebab-case to Title Case +function formatTitle(str) { + return str + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} diff --git a/docs/api/authentication.md b/docs/api/authentication.md new file mode 100644 index 0000000..0687546 --- /dev/null +++ b/docs/api/authentication.md @@ -0,0 +1,389 @@ +# API Authentication + +Core PHP Framework provides multiple authentication methods for API access, including API keys, OAuth tokens, and session-based authentication. + +## API Keys + +API keys are the primary authentication method for external API access. + +### Creating API Keys + +```php +use Mod\Api\Models\ApiKey; + +$apiKey = ApiKey::create([ + 'name' => 'Mobile App', + 'workspace_id' => $workspace->id, + 'scopes' => ['posts:read', 'posts:write', 'categories:read'], + 'rate_limit_tier' => 'pro', +]); + +// Get plaintext key (only shown once!) +$plaintext = $apiKey->plaintext_key; // sk_live_... +``` + +**Response:** +```json +{ + "id": 123, + "name": "Mobile App", + "key": "sk_live_abc123...", + "scopes": ["posts:read", "posts:write"], + "rate_limit_tier": "pro", + "created_at": "2026-01-26T12:00:00Z" +} +``` + +::: warning +The plaintext API key is only shown once at creation. Store it securely! +::: + +### Using API Keys + +Include the API key in the `Authorization` header: + +```bash +curl -H "Authorization: Bearer sk_live_abc123..." \ + https://api.example.com/v1/posts +``` + +Or use basic authentication: + +```bash +curl -u sk_live_abc123: \ + https://api.example.com/v1/posts +``` + +### Key Format + +API keys follow the format: `{prefix}_{environment}_{random}` + +- **Prefix:** `sk` (secret key) +- **Environment:** `live` or `test` +- **Random:** 32 characters + +**Examples:** +- `sk_live_` +- `sk_test_` + +### Key Security + +API keys are hashed with bcrypt before storage: + +```php +// Creation +$hash = bcrypt($plaintext); + +// Verification +if (Hash::check($providedKey, $apiKey->key_hash)) { + // Valid key +} +``` + +**Security Features:** +- Never stored in plaintext +- Bcrypt hashing (cost factor: 10) +- Secure comparison with `hash_equals()` +- Rate limiting per key +- Automatic expiry support + +### Key Rotation + +Rotate keys regularly for security: + +```php +$newKey = $apiKey->rotate(); + +// Returns new key object with: +// - New plaintext key +// - Same scopes and settings +// - Old key marked for deletion after grace period +``` + +**Grace Period:** +- Default: 24 hours +- Both old and new keys work during this period +- Old key auto-deleted after grace period + +### Key Permissions + +Control what each key can access: + +```php +$apiKey = ApiKey::create([ + 'name' => 'Read-Only Key', + 'scopes' => [ + 'posts:read', + 'categories:read', + 'analytics:read', + ], +]); +``` + +Available scopes documented in [Scopes & Permissions](#scopes--permissions). + +## Sanctum Tokens + +Laravel Sanctum provides token-based authentication for SPAs: + +### Creating Tokens + +```php +$user = User::find(1); + +$token = $user->createToken('mobile-app', [ + 'posts:read', + 'posts:write', +])->plainTextToken; +``` + +### Using Tokens + +```bash +curl -H "Authorization: Bearer 1|abc123..." \ + https://api.example.com/v1/posts +``` + +### Token Abilities + +Check token abilities in controllers: + +```php +if ($request->user()->tokenCan('posts:write')) { + // User has permission +} +``` + +## Session Authentication + +For first-party applications, use session-based authentication: + +```bash +# Login first +curl -X POST https://api.example.com/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"secret"}' \ + -c cookies.txt + +# Use session cookie +curl https://api.example.com/v1/posts \ + -b cookies.txt +``` + +## OAuth 2.0 (Optional) + +If Laravel Passport is installed, OAuth 2.0 is available: + +### Authorization Code Grant + +```bash +# 1. Redirect user to authorization endpoint +https://api.example.com/oauth/authorize? + client_id=CLIENT_ID& + redirect_uri=CALLBACK_URL& + response_type=code& + scope=posts:read posts:write + +# 2. Exchange code for token +curl -X POST https://api.example.com/oauth/token \ + -d "grant_type=authorization_code" \ + -d "client_id=CLIENT_ID" \ + -d "client_secret=CLIENT_SECRET" \ + -d "code=AUTH_CODE" \ + -d "redirect_uri=CALLBACK_URL" +``` + +### Client Credentials Grant + +For server-to-server: + +```bash +curl -X POST https://api.example.com/oauth/token \ + -d "grant_type=client_credentials" \ + -d "client_id=CLIENT_ID" \ + -d "client_secret=CLIENT_SECRET" \ + -d "scope=posts:read" +``` + +## Scopes & Permissions + +### Available Scopes + +| Scope | Description | +|-------|-------------| +| `posts:read` | Read blog posts | +| `posts:write` | Create and update posts | +| `posts:delete` | Delete posts | +| `categories:read` | Read categories | +| `categories:write` | Create and update categories | +| `analytics:read` | Access analytics data | +| `webhooks:manage` | Manage webhook endpoints | +| `keys:manage` | Manage API keys | +| `admin:*` | Full admin access | + +### Scope Enforcement + +Protect routes with scope middleware: + +```php +Route::middleware('scope:posts:write') + ->post('/posts', [PostController::class, 'store']); +``` + +### Wildcard Scopes + +Use wildcards for broad permissions: + +- `posts:*` - All post permissions +- `*:read` - Read access to all resources +- `*` - Full access (use sparingly!) + +## Authentication Errors + +### 401 Unauthorized + +Missing or invalid credentials: + +```json +{ + "message": "Unauthenticated." +} +``` + +**Causes:** +- No `Authorization` header +- Invalid API key +- Expired token +- Revoked credentials + +### 403 Forbidden + +Valid credentials but insufficient permissions: + +```json +{ + "message": "This action is unauthorized.", + "required_scope": "posts:write", + "provided_scopes": ["posts:read"] +} +``` + +**Causes:** +- Missing required scope +- Workspace suspended +- Resource access denied + +## Best Practices + +### 1. Use Minimum Required Scopes + +```php +// ✅ Good - specific scopes +$apiKey->scopes = ['posts:read', 'categories:read']; + +// ❌ Bad - excessive permissions +$apiKey->scopes = ['*']; +``` + +### 2. Rotate Keys Regularly + +```php +// Rotate every 90 days +if ($apiKey->created_at->diffInDays() > 90) { + $apiKey->rotate(); +} +``` + +### 3. Use Different Keys Per Client + +```php +// ✅ Good - separate keys +ApiKey::create(['name' => 'Mobile App iOS']); +ApiKey::create(['name' => 'Mobile App Android']); + +// ❌ Bad - shared key +ApiKey::create(['name' => 'All Mobile Apps']); +``` + +### 4. Monitor Key Usage + +```php +$usage = ApiKey::find($id)->usage() + ->whereBetween('created_at', [now()->subDays(7), now()]) + ->count(); +``` + +### 5. Implement Key Expiry + +```php +$apiKey = ApiKey::create([ + 'name' => 'Temporary Key', + 'expires_at' => now()->addDays(30), +]); +``` + +## Rate Limiting + +All authenticated requests are rate limited based on tier: + +| Tier | Requests per Hour | +|------|------------------| +| Free | 1,000 | +| Pro | 10,000 | +| Enterprise | Unlimited | + +Rate limit headers included in responses: + +``` +X-RateLimit-Limit: 10000 +X-RateLimit-Remaining: 9995 +X-RateLimit-Reset: 1640995200 +``` + +## Testing Authentication + +### Test Mode Keys + +Use test keys for development: + +```php +$testKey = ApiKey::create([ + 'name' => 'Test Key', + 'environment' => 'test', +]); + +// Key prefix: sk_test_... +``` + +Test keys: +- Don't affect production data +- Higher rate limits +- Clearly marked in admin panel +- Can be deleted without confirmation + +### cURL Examples + +**API Key:** +```bash +curl -H "Authorization: Bearer sk_live_..." \ + https://api.example.com/v1/posts +``` + +**Sanctum Token:** +```bash +curl -H "Authorization: Bearer 1|..." \ + https://api.example.com/v1/posts +``` + +**Session:** +```bash +curl -H "Cookie: laravel_session=..." \ + https://api.example.com/v1/posts +``` + +## Learn More + +- [API Reference →](/api/endpoints) +- [Rate Limiting →](/api/endpoints#rate-limiting) +- [Error Handling →](/api/errors) +- [API Package →](/packages/api) diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md new file mode 100644 index 0000000..5f6af1d --- /dev/null +++ b/docs/api/endpoints.md @@ -0,0 +1,743 @@ +# API Endpoints Reference + +Core PHP Framework provides RESTful APIs for programmatic access to platform resources. All endpoints follow consistent patterns for authentication, pagination, filtering, and error handling. + +## Base URL + +``` +https://your-domain.com/api/v1 +``` + +## Common Parameters + +### Pagination + +All list endpoints support pagination: + +```http +GET /api/v1/resources?page=2&per_page=50 +``` + +**Parameters:** +- `page` (integer) - Page number (default: 1) +- `per_page` (integer) - Items per page (default: 15, max: 100) + +**Response includes:** +```json +{ + "data": [...], + "meta": { + "current_page": 2, + "per_page": 50, + "total": 250, + "last_page": 5 + }, + "links": { + "first": "https://api.example.com/resources?page=1", + "last": "https://api.example.com/resources?page=5", + "prev": "https://api.example.com/resources?page=1", + "next": "https://api.example.com/resources?page=3" + } +} +``` + +### Filtering + +Filter list results using query parameters: + +```http +GET /api/v1/resources?status=active&created_after=2024-01-01 +``` + +Common filters: +- `status` - Filter by status (varies by resource) +- `created_after` - ISO 8601 date +- `created_before` - ISO 8601 date +- `updated_after` - ISO 8601 date +- `updated_before` - ISO 8601 date +- `search` - Full-text search (if supported) + +### Sorting + +Sort results using the `sort` parameter: + +```http +GET /api/v1/resources?sort=-created_at,name +``` + +- Prefix with `-` for descending order +- Default is ascending order +- Comma-separate multiple sort fields + +### Field Selection + +Request specific fields only: + +```http +GET /api/v1/resources?fields=id,name,created_at +``` + +Reduces payload size and improves performance. + +### Includes + +Eager-load related resources: + +```http +GET /api/v1/resources?include=owner,tags,metadata +``` + +Reduces number of API calls needed. + +## Rate Limiting + +API requests are rate-limited based on your tier: + +| Tier | Requests/Hour | Burst | +|------|--------------|-------| +| Free | 1,000 | 50 | +| Pro | 10,000 | 200 | +| Business | 50,000 | 500 | +| Enterprise | Custom | Custom | + +Rate limit headers included in every response: + +```http +X-RateLimit-Limit: 10000 +X-RateLimit-Remaining: 9847 +X-RateLimit-Reset: 1640995200 +``` + +When rate limit is exceeded, you'll receive a `429 Too Many Requests` response: + +```json +{ + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded. Please retry after 3600 seconds.", + "retry_after": 3600 + } +} +``` + +## Idempotency + +POST, PATCH, PUT, and DELETE requests support idempotency keys to safely retry requests: + +```http +POST /api/v1/resources +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +``` + +If the same idempotency key is used within 24 hours: +- Same status code and response body returned +- No duplicate resource created +- Safe to retry failed requests + +## Versioning + +The API version is included in the URL path: + +``` +/api/v1/resources +``` + +When breaking changes are introduced, a new version will be released (e.g., `/api/v2/`). Previous versions are supported for at least 12 months after deprecation notice. + +## Workspaces & Namespaces + +Multi-tenant resources require workspace and/or namespace context: + +```http +GET /api/v1/resources +X-Workspace-ID: 123 +X-Namespace-ID: 456 +``` + +Alternatively, use query parameters: + +```http +GET /api/v1/resources?workspace_id=123&namespace_id=456 +``` + +See [Namespaces & Entitlements](/security/namespaces) for details on multi-tenancy. + +## Webhook Events + +Configure webhooks to receive real-time notifications: + +```http +POST /api/v1/webhooks +{ + "url": "https://your-app.com/webhooks", + "events": ["resource.created", "resource.updated"], + "secret": "whsec_abc123..." +} +``` + +**Common events:** +- `{resource}.created` - Resource created +- `{resource}.updated` - Resource updated +- `{resource}.deleted` - Resource deleted + +**Webhook payload:** +```json +{ + "id": "evt_1234567890", + "type": "resource.created", + "created_at": "2024-01-15T10:30:00Z", + "data": { + "object": { + "id": "res_abc123", + "type": "resource", + "attributes": {...} + } + } +} +``` + +Webhook requests include HMAC-SHA256 signature in headers: + +```http +X-Webhook-Signature: sha256=abc123... +X-Webhook-Timestamp: 1640995200 +``` + +See [Webhook Security](/api/authentication#webhook-signatures) for signature verification. + +## Error Handling + +All errors follow a consistent format. See [Error Reference](/api/errors) for details. + +**Example error response:** + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Validation failed", + "details": { + "email": ["The email field is required."] + }, + "request_id": "req_abc123" + } +} +``` + +## Resource Endpoints + +### Core Resources + +The following resource types are available: + +- **Workspaces** - Multi-tenant workspaces +- **Namespaces** - Service isolation contexts +- **Users** - User accounts +- **API Keys** - API authentication credentials +- **Webhooks** - Webhook endpoints + +### Workspace Endpoints + +#### List Workspaces + +```http +GET /api/v1/workspaces +``` + +**Response:** +```json +{ + "data": [ + { + "id": "wks_abc123", + "name": "Acme Corporation", + "slug": "acme-corp", + "tier": "business", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T10:30:00Z" + } + ] +} +``` + +#### Get Workspace + +```http +GET /api/v1/workspaces/{workspace_id} +``` + +**Response:** +```json +{ + "data": { + "id": "wks_abc123", + "name": "Acme Corporation", + "slug": "acme-corp", + "tier": "business", + "settings": { + "timezone": "UTC", + "locale": "en_GB" + }, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T10:30:00Z" + } +} +``` + +#### Create Workspace + +```http +POST /api/v1/workspaces +``` + +**Request:** +```json +{ + "name": "New Workspace", + "slug": "new-workspace", + "tier": "pro" +} +``` + +**Response:** `201 Created` + +#### Update Workspace + +```http +PATCH /api/v1/workspaces/{workspace_id} +``` + +**Request:** +```json +{ + "name": "Updated Name", + "settings": { + "timezone": "Europe/London" + } +} +``` + +**Response:** `200 OK` + +#### Delete Workspace + +```http +DELETE /api/v1/workspaces/{workspace_id} +``` + +**Response:** `204 No Content` + +### Namespace Endpoints + +#### List Namespaces + +```http +GET /api/v1/namespaces +``` + +**Query parameters:** +- `owner_type` - Filter by owner type (`User` or `Workspace`) +- `workspace_id` - Filter by workspace +- `is_active` - Filter by active status + +**Response:** +```json +{ + "data": [ + { + "id": "ns_abc123", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "name": "Personal Namespace", + "slug": "personal", + "owner_type": "User", + "owner_id": 42, + "workspace_id": null, + "is_default": true, + "is_active": true, + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +#### Get Namespace + +```http +GET /api/v1/namespaces/{namespace_id} +``` + +**Response:** +```json +{ + "data": { + "id": "ns_abc123", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "name": "Client: Acme Corp", + "slug": "client-acme", + "owner_type": "Workspace", + "owner_id": 10, + "workspace_id": 10, + "packages": [ + { + "id": "pkg_starter", + "name": "Starter Package", + "expires_at": null + } + ], + "entitlements": { + "storage": { + "used": 1024000000, + "limit": 5368709120, + "unit": "bytes" + }, + "api_calls": { + "used": 5430, + "limit": 10000, + "reset_at": "2024-02-01T00:00:00Z" + } + } + } +} +``` + +#### Check Entitlement + +```http +POST /api/v1/namespaces/{namespace_id}/entitlements/check +``` + +**Request:** +```json +{ + "feature": "storage", + "quantity": 1073741824 +} +``` + +**Response:** +```json +{ + "allowed": false, + "reason": "LIMIT_EXCEEDED", + "message": "Storage limit exceeded. Used: 1.00 GB, Available: 0.50 GB, Requested: 1.00 GB", + "current_usage": 1024000000, + "limit": 5368709120, + "available": 536870912 +} +``` + +### User Endpoints + +#### List Users + +```http +GET /api/v1/users +X-Workspace-ID: 123 +``` + +**Response:** +```json +{ + "data": [ + { + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "tier": "pro", + "email_verified_at": "2024-01-01T12:00:00Z", + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +#### Get Current User + +```http +GET /api/v1/user +``` + +Returns the authenticated user. + +#### Update User + +```http +PATCH /api/v1/users/{user_id} +``` + +**Request:** +```json +{ + "name": "Jane Doe", + "email": "jane@example.com" +} +``` + +### API Key Endpoints + +#### List API Keys + +```http +GET /api/v1/api-keys +``` + +**Response:** +```json +{ + "data": [ + { + "id": "key_abc123", + "name": "Production API Key", + "prefix": "sk_live_", + "last_used_at": "2024-01-15T10:30:00Z", + "expires_at": null, + "scopes": ["read:all", "write:resources"], + "rate_limit_tier": "business", + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +#### Create API Key + +```http +POST /api/v1/api-keys +``` + +**Request:** +```json +{ + "name": "New API Key", + "scopes": ["read:all"], + "rate_limit_tier": "pro", + "expires_at": "2025-01-01T00:00:00Z" +} +``` + +**Response:** +```json +{ + "data": { + "id": "key_abc123", + "name": "New API Key", + "key": "sk_live_abc123def456...", + "scopes": ["read:all"], + "created_at": "2024-01-15T10:30:00Z" + } +} +``` + +⚠️ **Important:** The `key` field is only returned once during creation. Store it securely. + +#### Revoke API Key + +```http +DELETE /api/v1/api-keys/{key_id} +``` + +**Response:** `204 No Content` + +### Webhook Endpoints + +#### List Webhooks + +```http +GET /api/v1/webhooks +``` + +**Response:** +```json +{ + "data": [ + { + "id": "wh_abc123", + "url": "https://your-app.com/webhooks", + "events": ["resource.created", "resource.updated"], + "is_active": true, + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +#### Create Webhook + +```http +POST /api/v1/webhooks +``` + +**Request:** +```json +{ + "url": "https://your-app.com/webhooks", + "events": ["resource.created"], + "secret": "whsec_abc123..." +} +``` + +#### Test Webhook + +```http +POST /api/v1/webhooks/{webhook_id}/test +``` + +Sends a test event to the webhook URL. + +**Response:** +```json +{ + "success": true, + "status_code": 200, + "response_time_ms": 145 +} +``` + +#### Webhook Deliveries + +```http +GET /api/v1/webhooks/{webhook_id}/deliveries +``` + +View delivery history and retry failed deliveries: + +```json +{ + "data": [ + { + "id": "del_abc123", + "event_type": "resource.created", + "status": "success", + "status_code": 200, + "attempts": 1, + "delivered_at": "2024-01-15T10:30:00Z" + } + ] +} +``` + +## Best Practices + +### 1. Use Idempotency Keys + +Always use idempotency keys for create/update operations: + +```javascript +const response = await fetch('/api/v1/resources', { + method: 'POST', + headers: { + 'Idempotency-Key': crypto.randomUUID(), + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify(data) +}); +``` + +### 2. Handle Rate Limits + +Respect rate limit headers and implement exponential backoff: + +```javascript +async function apiRequest(url, options) { + const response = await fetch(url, options); + + if (response.status === 429) { + const retryAfter = response.headers.get('X-RateLimit-Reset'); + await sleep(retryAfter * 1000); + return apiRequest(url, options); // Retry + } + + return response; +} +``` + +### 3. Use Field Selection + +Request only needed fields to reduce payload size: + +```http +GET /api/v1/resources?fields=id,name,status +``` + +### 4. Batch Operations + +When possible, use batch endpoints instead of multiple single requests: + +```http +POST /api/v1/resources/batch +{ + "operations": [ + {"action": "create", "data": {...}}, + {"action": "update", "id": "res_123", "data": {...}} + ] +} +``` + +### 5. Verify Webhook Signatures + +Always verify webhook signatures to ensure authenticity: + +```javascript +const crypto = require('crypto'); + +function verifyWebhook(payload, signature, secret) { + const hmac = crypto.createHmac('sha256', secret); + hmac.update(payload); + const expected = 'sha256=' + hmac.digest('hex'); + + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); +} +``` + +### 6. Store API Keys Securely + +- Never commit API keys to version control +- Use environment variables or secrets management +- Rotate keys regularly +- Use separate keys for development/production + +### 7. Monitor Usage + +Track your API usage to avoid hitting rate limits: + +```http +GET /api/v1/usage +``` + +Returns current usage statistics for your account. + +## SDKs & Libraries + +Official SDKs available: + +- **PHP:** `composer require core-php/sdk` +- **JavaScript/Node.js:** `npm install @core-php/sdk` +- **Python:** `pip install core-php-sdk` + +**Example (PHP):** + +```php +use CorePhp\SDK\Client; + +$client = new Client('sk_live_abc123...'); + +$workspace = $client->workspaces->create([ + 'name' => 'My Workspace', + 'tier' => 'pro', +]); + +$namespaces = $client->namespaces->list([ + 'workspace_id' => $workspace->id, +]); +``` + +## Further Reading + +- [Authentication](/api/authentication) - API key management and authentication methods +- [Error Handling](/api/errors) - Error codes and debugging +- [Namespaces & Entitlements](/security/namespaces) - Multi-tenancy and feature access +- [Webhooks Guide](#webhook-events) - Setting up webhook endpoints +- [Rate Limiting](#rate-limiting) - Understanding rate limits and tiers diff --git a/docs/api/errors.md b/docs/api/errors.md new file mode 100644 index 0000000..56f5e04 --- /dev/null +++ b/docs/api/errors.md @@ -0,0 +1,525 @@ +# API Errors + +Core PHP Framework uses conventional HTTP response codes and provides detailed error information to help you debug issues. + +## HTTP Status Codes + +### 2xx Success + +| Code | Status | Description | +|------|--------|-------------| +| 200 | OK | Request succeeded | +| 201 | Created | Resource created successfully | +| 202 | Accepted | Request accepted for processing | +| 204 | No Content | Request succeeded, no content to return | + +### 4xx Client Errors + +| Code | Status | Description | +|------|--------|-------------| +| 400 | Bad Request | Invalid request format or parameters | +| 401 | Unauthorized | Missing or invalid authentication | +| 403 | Forbidden | Authenticated but not authorized | +| 404 | Not Found | Resource doesn't exist | +| 405 | Method Not Allowed | HTTP method not supported for endpoint | +| 409 | Conflict | Request conflicts with current state | +| 422 | Unprocessable Entity | Validation failed | +| 429 | Too Many Requests | Rate limit exceeded | + +### 5xx Server Errors + +| Code | Status | Description | +|------|--------|-------------| +| 500 | Internal Server Error | Unexpected server error | +| 502 | Bad Gateway | Invalid response from upstream server | +| 503 | Service Unavailable | Server temporarily unavailable | +| 504 | Gateway Timeout | Upstream server timeout | + +## Error Response Format + +All errors return JSON with consistent structure: + +```json +{ + "message": "Human-readable error message", + "error_code": "MACHINE_READABLE_CODE", + "errors": { + "field": ["Detailed validation errors"] + }, + "meta": { + "timestamp": "2026-01-26T12:00:00Z", + "request_id": "req_abc123" + } +} +``` + +## Common Errors + +### 400 Bad Request + +**Missing Required Parameter:** +```json +{ + "message": "Missing required parameter: title", + "error_code": "MISSING_PARAMETER", + "errors": { + "title": ["The title field is required."] + } +} +``` + +**Invalid Parameter Type:** +```json +{ + "message": "Invalid parameter type", + "error_code": "INVALID_TYPE", + "errors": { + "published_at": ["The published at must be a valid date."] + } +} +``` + +### 401 Unauthorized + +**Missing Authentication:** +```json +{ + "message": "Unauthenticated.", + "error_code": "UNAUTHENTICATED" +} +``` + +**Invalid API Key:** +```json +{ + "message": "Invalid API key", + "error_code": "INVALID_API_KEY" +} +``` + +**Expired Token:** +```json +{ + "message": "Token has expired", + "error_code": "TOKEN_EXPIRED", + "meta": { + "expired_at": "2026-01-20T12:00:00Z" + } +} +``` + +### 403 Forbidden + +**Insufficient Permissions:** +```json +{ + "message": "This action is unauthorized.", + "error_code": "INSUFFICIENT_PERMISSIONS", + "required_scope": "posts:write", + "provided_scopes": ["posts:read"] +} +``` + +**Workspace Suspended:** +```json +{ + "message": "Workspace is suspended", + "error_code": "WORKSPACE_SUSPENDED", + "meta": { + "suspended_at": "2026-01-25T12:00:00Z", + "reason": "Payment overdue" + } +} +``` + +**Namespace Access Denied:** +```json +{ + "message": "You do not have access to this namespace", + "error_code": "NAMESPACE_ACCESS_DENIED" +} +``` + +### 404 Not Found + +**Resource Not Found:** +```json +{ + "message": "Post not found", + "error_code": "RESOURCE_NOT_FOUND", + "resource_type": "Post", + "resource_id": 999 +} +``` + +**Endpoint Not Found:** +```json +{ + "message": "Endpoint not found", + "error_code": "ENDPOINT_NOT_FOUND", + "requested_path": "/v1/nonexistent" +} +``` + +### 409 Conflict + +**Duplicate Resource:** +```json +{ + "message": "A post with this slug already exists", + "error_code": "DUPLICATE_RESOURCE", + "conflicting_field": "slug", + "existing_resource_id": 123 +} +``` + +**State Conflict:** +```json +{ + "message": "Post is already published", + "error_code": "STATE_CONFLICT", + "current_state": "published", + "requested_action": "publish" +} +``` + +### 422 Unprocessable Entity + +**Validation Failed:** +```json +{ + "message": "The given data was invalid.", + "error_code": "VALIDATION_FAILED", + "errors": { + "title": [ + "The title field is required." + ], + "content": [ + "The content must be at least 10 characters." + ], + "category_id": [ + "The selected category is invalid." + ] + } +} +``` + +### 429 Too Many Requests + +**Rate Limit Exceeded:** +```json +{ + "message": "Too many requests", + "error_code": "RATE_LIMIT_EXCEEDED", + "limit": 10000, + "remaining": 0, + "reset_at": "2026-01-26T13:00:00Z", + "retry_after": 3600 +} +``` + +**Usage Quota Exceeded:** +```json +{ + "message": "Monthly usage quota exceeded", + "error_code": "QUOTA_EXCEEDED", + "quota_type": "monthly", + "limit": 50000, + "used": 50000, + "reset_at": "2026-02-01T00:00:00Z" +} +``` + +### 500 Internal Server Error + +**Unexpected Error:** +```json +{ + "message": "An unexpected error occurred", + "error_code": "INTERNAL_ERROR", + "meta": { + "request_id": "req_abc123", + "timestamp": "2026-01-26T12:00:00Z" + } +} +``` + +::: tip +In production, internal error messages are sanitized. Include the `request_id` when reporting issues for debugging. +::: + +## Error Codes + +### Authentication Errors + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `UNAUTHENTICATED` | 401 | No authentication provided | +| `INVALID_API_KEY` | 401 | API key is invalid or revoked | +| `TOKEN_EXPIRED` | 401 | Authentication token has expired | +| `INVALID_CREDENTIALS` | 401 | Username/password incorrect | +| `INSUFFICIENT_PERMISSIONS` | 403 | Missing required permissions/scopes | + +### Resource Errors + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `RESOURCE_NOT_FOUND` | 404 | Requested resource doesn't exist | +| `DUPLICATE_RESOURCE` | 409 | Resource with identifier already exists | +| `RESOURCE_LOCKED` | 409 | Resource is locked by another process | +| `STATE_CONFLICT` | 409 | Action conflicts with current state | + +### Validation Errors + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `VALIDATION_FAILED` | 422 | One or more fields failed validation | +| `INVALID_TYPE` | 400 | Parameter has wrong data type | +| `MISSING_PARAMETER` | 400 | Required parameter not provided | +| `INVALID_FORMAT` | 400 | Parameter format is invalid | + +### Rate Limiting Errors + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests in time window | +| `QUOTA_EXCEEDED` | 429 | Usage quota exceeded | +| `CONCURRENT_LIMIT_EXCEEDED` | 429 | Too many concurrent requests | + +### Business Logic Errors + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `ENTITLEMENT_DENIED` | 403 | Feature not included in plan | +| `WORKSPACE_SUSPENDED` | 403 | Workspace is suspended | +| `NAMESPACE_ACCESS_DENIED` | 403 | No access to namespace | +| `PAYMENT_REQUIRED` | 402 | Payment required to proceed | + +### System Errors + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `INTERNAL_ERROR` | 500 | Unexpected server error | +| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable | +| `GATEWAY_TIMEOUT` | 504 | Upstream service timeout | +| `MAINTENANCE_MODE` | 503 | System under maintenance | + +## Handling Errors + +### JavaScript Example + +```javascript +async function createPost(data) { + try { + const response = await fetch('/api/v1/posts', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json(); + + switch (response.status) { + case 401: + // Re-authenticate + redirectToLogin(); + break; + case 403: + // Show permission error + showError('You do not have permission to create posts'); + break; + case 422: + // Show validation errors + showValidationErrors(error.errors); + break; + case 429: + // Show rate limit message + showError(`Rate limited. Retry after ${error.retry_after} seconds`); + break; + default: + // Generic error + showError(error.message); + } + + return null; + } + + return await response.json(); + } catch (err) { + // Network error + showError('Network error. Please check your connection.'); + return null; + } +} +``` + +### PHP Example + +```php +use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; + +$client = new Client(['base_uri' => 'https://api.example.com']); + +try { + $response = $client->post('/v1/posts', [ + 'headers' => [ + 'Authorization' => "Bearer {$apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $data, + ]); + + $post = json_decode($response->getBody(), true); + +} catch (RequestException $e) { + $statusCode = $e->getResponse()->getStatusCode(); + $error = json_decode($e->getResponse()->getBody(), true); + + switch ($statusCode) { + case 401: + throw new AuthenticationException($error['message']); + case 403: + throw new AuthorizationException($error['message']); + case 422: + throw new ValidationException($error['errors']); + case 429: + throw new RateLimitException($error['retry_after']); + default: + throw new ApiException($error['message']); + } +} +``` + +## Debugging + +### Request ID + +Every response includes a `request_id` for debugging: + +```bash +curl -i https://api.example.com/v1/posts +``` + +Response headers: +``` +X-Request-ID: req_abc123def456 +``` + +Include this ID when reporting issues. + +### Debug Mode + +In development, enable debug mode for detailed errors: + +```php +// .env +APP_DEBUG=true +``` + +Debug responses include: +- Full stack traces +- SQL queries +- Exception details + +::: danger +Never enable debug mode in production! It exposes sensitive information. +::: + +### Logging + +All errors are logged with context: + +``` +[2026-01-26 12:00:00] production.ERROR: Post not found +{ + "user_id": 123, + "workspace_id": 456, + "namespace_id": 789, + "post_id": 999, + "request_id": "req_abc123" +} +``` + +## Best Practices + +### 1. Always Check Status Codes + +```javascript +// ✅ Good +if (!response.ok) { + handleError(response); +} + +// ❌ Bad - assumes success +const data = await response.json(); +``` + +### 2. Handle All Error Types + +```javascript +// ✅ Good - specific handling +switch (error.error_code) { + case 'RATE_LIMIT_EXCEEDED': + retryAfter(error.retry_after); + break; + case 'VALIDATION_FAILED': + showValidationErrors(error.errors); + break; + default: + showGenericError(error.message); +} + +// ❌ Bad - generic handling +alert(error.message); +``` + +### 3. Implement Retry Logic + +```javascript +async function fetchWithRetry(url, options, retries = 3) { + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(url, options); + + if (response.status === 429) { + // Rate limited - wait and retry + const retryAfter = parseInt(response.headers.get('Retry-After')); + await sleep(retryAfter * 1000); + continue; + } + + return response; + } catch (err) { + if (i === retries - 1) throw err; + await sleep(1000 * Math.pow(2, i)); // Exponential backoff + } + } +} +``` + +### 4. Log Error Context + +```javascript +// ✅ Good - log context +console.error('API Error:', { + endpoint: '/v1/posts', + method: 'POST', + status: response.status, + error_code: error.error_code, + request_id: error.meta.request_id +}); + +// ❌ Bad - no context +console.error(error.message); +``` + +## Learn More + +- [API Authentication →](/api/authentication) +- [Rate Limiting →](/api/endpoints#rate-limiting) +- [API Endpoints →](/api/endpoints) diff --git a/docs/build/cli/ai/example.md b/docs/build/cli/ai/example.md new file mode 100644 index 0000000..b115b09 --- /dev/null +++ b/docs/build/cli/ai/example.md @@ -0,0 +1,100 @@ +# AI Examples + +## Workflow Example + +Complete task management workflow: + +```bash +# 1. List available tasks +core ai tasks --status pending + +# 2. Auto-select and claim a task +core ai task --auto --claim + +# 3. Work on the task... + +# 4. Update progress +core ai task:update abc123 --progress 75 + +# 5. Commit with task reference +core ai task:commit abc123 -m 'implement feature' + +# 6. Create PR +core ai task:pr abc123 + +# 7. Mark complete +core ai task:complete abc123 --output 'Feature implemented and PR created' +``` + +## Task Filtering + +```bash +# By status +core ai tasks --status pending +core ai tasks --status in_progress + +# By priority +core ai tasks --priority critical +core ai tasks --priority high + +# By labels +core ai tasks --labels bug,urgent + +# Combined filters +core ai tasks --status pending --priority high --labels bug +``` + +## Task Updates + +```bash +# Change status +core ai task:update abc123 --status in_progress +core ai task:update abc123 --status blocked + +# Update progress +core ai task:update abc123 --progress 25 +core ai task:update abc123 --progress 50 --notes 'Halfway done' +core ai task:update abc123 --progress 100 +``` + +## Git Integration + +```bash +# Commit with task reference +core ai task:commit abc123 -m 'add authentication' + +# With scope +core ai task:commit abc123 -m 'fix login' --scope auth + +# Commit and push +core ai task:commit abc123 -m 'complete feature' --push + +# Create PR +core ai task:pr abc123 + +# Draft PR +core ai task:pr abc123 --draft + +# PR with labels +core ai task:pr abc123 --labels 'enhancement,ready-for-review' + +# PR to different base +core ai task:pr abc123 --base develop +``` + +## Configuration + +### Environment Variables + +```env +AGENTIC_TOKEN=your-api-token +AGENTIC_BASE_URL=https://agentic.example.com +``` + +### ~/.core/agentic.yaml + +```yaml +token: your-api-token +base_url: https://agentic.example.com +default_project: my-project +``` diff --git a/docs/build/cli/ai/index.md b/docs/build/cli/ai/index.md new file mode 100644 index 0000000..f6c49be --- /dev/null +++ b/docs/build/cli/ai/index.md @@ -0,0 +1,262 @@ +# core ai + +AI agent task management and Claude Code integration. + +## Task Management Commands + +| Command | Description | +|---------|-------------| +| `tasks` | List available tasks from core-agentic | +| `task` | View task details or auto-select | +| `task:update` | Update task status or progress | +| `task:complete` | Mark task as completed or failed | +| `task:commit` | Create git commit with task reference | +| `task:pr` | Create GitHub PR linked to task | + +## Claude Integration + +| Command | Description | +|---------|-------------| +| `claude run` | Run Claude Code in current directory | +| `claude config` | Manage Claude configuration | + +--- + +## Configuration + +Task commands load configuration from: +1. Environment variables (`AGENTIC_TOKEN`, `AGENTIC_BASE_URL`) +2. `.env` file in current directory +3. `~/.core/agentic.yaml` + +--- + +## ai tasks + +List available tasks from core-agentic. + +```bash +core ai tasks [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--status` | Filter by status (`pending`, `in_progress`, `completed`, `blocked`) | +| `--priority` | Filter by priority (`critical`, `high`, `medium`, `low`) | +| `--labels` | Filter by labels (comma-separated) | +| `--project` | Filter by project | +| `--limit` | Max number of tasks to return (default: 20) | + +### Examples + +```bash +# List all pending tasks +core ai tasks + +# Filter by status and priority +core ai tasks --status pending --priority high + +# Filter by labels +core ai tasks --labels bug,urgent +``` + +--- + +## ai task + +View task details or auto-select a task. + +```bash +core ai task [task-id] [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--auto` | Auto-select highest priority pending task | +| `--claim` | Claim the task after showing details | +| `--context` | Show gathered context for AI collaboration | + +### Examples + +```bash +# Show task details +core ai task abc123 + +# Show and claim +core ai task abc123 --claim + +# Show with context +core ai task abc123 --context + +# Auto-select highest priority pending task +core ai task --auto +``` + +--- + +## ai task:update + +Update a task's status, progress, or notes. + +```bash +core ai task:update [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--status` | New status (`pending`, `in_progress`, `completed`, `blocked`) | +| `--progress` | Progress percentage (0-100) | +| `--notes` | Notes about the update | + +### Examples + +```bash +# Set task to in progress +core ai task:update abc123 --status in_progress + +# Update progress with notes +core ai task:update abc123 --progress 50 --notes 'Halfway done' +``` + +--- + +## ai task:complete + +Mark a task as completed with optional output and artifacts. + +```bash +core ai task:complete [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--output` | Summary of the completed work | +| `--failed` | Mark the task as failed | +| `--error` | Error message if failed | + +### Examples + +```bash +# Complete successfully +core ai task:complete abc123 --output 'Feature implemented' + +# Mark as failed +core ai task:complete abc123 --failed --error 'Build failed' +``` + +--- + +## ai task:commit + +Create a git commit with a task reference and co-author attribution. + +```bash +core ai task:commit [flags] +``` + +Commit message format: +``` +feat(scope): description + +Task: #123 +Co-Authored-By: Claude +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-m`, `--message` | Commit message (without task reference) | +| `--scope` | Scope for the commit type (e.g., `auth`, `api`, `ui`) | +| `--push` | Push changes after committing | + +### Examples + +```bash +# Commit with message +core ai task:commit abc123 --message 'add user authentication' + +# With scope +core ai task:commit abc123 -m 'fix login bug' --scope auth + +# Commit and push +core ai task:commit abc123 -m 'update docs' --push +``` + +--- + +## ai task:pr + +Create a GitHub pull request linked to a task. + +```bash +core ai task:pr [flags] +``` + +Requires the GitHub CLI (`gh`) to be installed and authenticated. + +### Flags + +| Flag | Description | +|------|-------------| +| `--title` | PR title (defaults to task title) | +| `--base` | Base branch (defaults to main) | +| `--draft` | Create as draft PR | +| `--labels` | Labels to add (comma-separated) | + +### Examples + +```bash +# Create PR with defaults +core ai task:pr abc123 + +# Custom title +core ai task:pr abc123 --title 'Add authentication feature' + +# Draft PR with labels +core ai task:pr abc123 --draft --labels 'enhancement,needs-review' + +# Target different base branch +core ai task:pr abc123 --base develop +``` + +--- + +## ai claude + +Claude Code integration commands. + +### ai claude run + +Run Claude Code in the current directory. + +```bash +core ai claude run +``` + +### ai claude config + +Manage Claude configuration. + +```bash +core ai claude config +``` + +--- + +## Workflow Example + +See [Workflow Example](example.md#workflow-example) for a complete task management workflow. + +## See Also + +- [dev](../dev/) - Multi-repo workflow commands +- [Claude Code documentation](https://claude.ai/code) diff --git a/docs/build/cli/build/example.md b/docs/build/cli/build/example.md new file mode 100644 index 0000000..da2f3b4 --- /dev/null +++ b/docs/build/cli/build/example.md @@ -0,0 +1,83 @@ +# Build Examples + +## Quick Start + +```bash +# Auto-detect and build +core build + +# Build for specific platforms +core build --targets linux/amd64,darwin/arm64 + +# CI mode +core build --ci +``` + +## Configuration + +`.core/build.yaml`: + +```yaml +version: 1 + +project: + name: myapp + binary: myapp + +build: + main: ./cmd/myapp + ldflags: + - -s -w + - -X main.version={{.Version}} + +targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: arm64 +``` + +## Cross-Platform Build + +```bash +core build --targets linux/amd64,linux/arm64,darwin/arm64,windows/amd64 +``` + +Output: +``` +dist/ +├── myapp-linux-amd64.tar.gz +├── myapp-linux-arm64.tar.gz +├── myapp-darwin-arm64.tar.gz +├── myapp-windows-amd64.zip +└── CHECKSUMS.txt +``` + +## Code Signing + +```yaml +sign: + enabled: true + gpg: + key: $GPG_KEY_ID + macos: + identity: "Developer ID Application: Your Name (TEAM_ID)" + notarize: true + apple_id: $APPLE_ID + team_id: $APPLE_TEAM_ID + app_password: $APPLE_APP_PASSWORD +``` + +## Docker Build + +```bash +core build --type docker --image ghcr.io/myorg/myapp +``` + +## Wails Desktop App + +```bash +core build --type wails --targets darwin/arm64,windows/amd64 +``` diff --git a/docs/build/cli/build/index.md b/docs/build/cli/build/index.md new file mode 100644 index 0000000..6956e65 --- /dev/null +++ b/docs/build/cli/build/index.md @@ -0,0 +1,176 @@ +# core build + +Build Go, Wails, Docker, and LinuxKit projects with automatic project detection. + +## Subcommands + +| Command | Description | +|---------|-------------| +| [sdk](sdk/) | Generate API SDKs from OpenAPI | +| `from-path` | Build from a local directory | +| `pwa` | Build from a live PWA URL | + +## Usage + +```bash +core build [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--type` | Project type: `go`, `wails`, `docker`, `linuxkit`, `taskfile` (auto-detected) | +| `--targets` | Build targets: `linux/amd64,darwin/arm64,windows/amd64` | +| `--output` | Output directory (default: `dist`) | +| `--ci` | CI mode - minimal output with JSON artifact list at the end | +| `--image` | Docker image name (for docker builds) | +| `--config` | Config file path (for linuxkit: YAML config, for docker: Dockerfile) | +| `--format` | Output format for linuxkit (iso-bios, qcow2-bios, raw, vmdk) | +| `--push` | Push Docker image after build (default: false) | +| `--archive` | Create archives (tar.gz for linux/darwin, zip for windows) - default: true | +| `--checksum` | Generate SHA256 checksums and CHECKSUMS.txt - default: true | +| `--no-sign` | Skip all code signing | +| `--notarize` | Enable macOS notarization (requires Apple credentials) | + +## Examples + +### Go Project + +```bash +# Auto-detect and build +core build + +# Build for specific platforms +core build --targets linux/amd64,linux/arm64,darwin/arm64 + +# CI mode +core build --ci +``` + +### Wails Project + +```bash +# Build Wails desktop app +core build --type wails + +# Build for all desktop platforms +core build --type wails --targets darwin/amd64,darwin/arm64,windows/amd64,linux/amd64 +``` + +### Docker Image + +```bash +# Build Docker image +core build --type docker + +# With custom image name +core build --type docker --image ghcr.io/myorg/myapp + +# Build and push to registry +core build --type docker --image ghcr.io/myorg/myapp --push +``` + +### LinuxKit Image + +```bash +# Build LinuxKit ISO +core build --type linuxkit + +# Build with specific format +core build --type linuxkit --config linuxkit.yml --format qcow2-bios +``` + +## Project Detection + +Core automatically detects project type based on files: + +| Files | Type | +|-------|------| +| `wails.json` | Wails | +| `go.mod` | Go | +| `Dockerfile` | Docker | +| `Taskfile.yml` | Taskfile | +| `composer.json` | PHP | +| `package.json` | Node | + +## Output + +Build artifacts are placed in `dist/` by default: + +``` +dist/ +├── myapp-linux-amd64.tar.gz +├── myapp-linux-arm64.tar.gz +├── myapp-darwin-amd64.tar.gz +├── myapp-darwin-arm64.tar.gz +├── myapp-windows-amd64.zip +└── CHECKSUMS.txt +``` + +## Configuration + +Optional `.core/build.yaml` - see [Configuration](example.md#configuration) for examples. + +## Code Signing + +Core supports GPG signing for checksums and native code signing for macOS. + +### GPG Signing + +Signs `CHECKSUMS.txt` with a detached ASCII signature (`.asc`): + +```bash +# Build with GPG signing (default if key configured) +core build + +# Skip signing +core build --no-sign +``` + +Users can verify: + +```bash +gpg --verify CHECKSUMS.txt.asc CHECKSUMS.txt +sha256sum -c CHECKSUMS.txt +``` + +### macOS Code Signing + +Signs Darwin binaries with your Developer ID and optionally notarizes with Apple: + +```bash +# Build with codesign (automatic if identity configured) +core build + +# Build with notarization (takes 1-5 minutes) +core build --notarize +``` + +### Environment Variables + +| Variable | Purpose | +|----------|---------| +| `GPG_KEY_ID` | GPG key ID or fingerprint | +| `CODESIGN_IDENTITY` | macOS Developer ID (fallback) | +| `APPLE_ID` | Apple account email | +| `APPLE_TEAM_ID` | Apple Developer Team ID | +| `APPLE_APP_PASSWORD` | App-specific password for notarization | + +## Building from PWAs and Static Sites + +### Build from Local Directory + +Build a desktop app from static web application files: + +```bash +core build from-path --path ./dist +``` + +### Build from Live PWA + +Build a desktop app from a live Progressive Web App URL: + +```bash +core build pwa --url https://example.com +``` diff --git a/docs/build/cli/build/sdk/example.md b/docs/build/cli/build/sdk/example.md new file mode 100644 index 0000000..e832308 --- /dev/null +++ b/docs/build/cli/build/sdk/example.md @@ -0,0 +1,56 @@ +# SDK Build Examples + +## Generate All SDKs + +```bash +core build sdk +``` + +## Specific Language + +```bash +core build sdk --lang typescript +core build sdk --lang php +core build sdk --lang go +``` + +## Custom Spec + +```bash +core build sdk --spec ./api/openapi.yaml +``` + +## With Version + +```bash +core build sdk --version v2.0.0 +``` + +## Preview + +```bash +core build sdk --dry-run +``` + +## Configuration + +`.core/sdk.yaml`: + +```yaml +version: 1 + +spec: ./api/openapi.yaml + +languages: + - name: typescript + output: sdk/typescript + package: "@myorg/api-client" + + - name: php + output: sdk/php + namespace: MyOrg\ApiClient + + - name: go + output: sdk/go + module: github.com/myorg/api-client-go +``` diff --git a/docs/build/cli/build/sdk/index.md b/docs/build/cli/build/sdk/index.md new file mode 100644 index 0000000..084c5ef --- /dev/null +++ b/docs/build/cli/build/sdk/index.md @@ -0,0 +1,27 @@ +# core build sdk + +Generate typed API clients from OpenAPI specifications. Supports TypeScript, Python, Go, and PHP. + +## Usage + +```bash +core build sdk [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--spec` | Path to OpenAPI spec file | +| `--lang` | Generate only this language (typescript, python, go, php) | +| `--version` | Version to embed in generated SDKs | +| `--dry-run` | Show what would be generated without writing files | + +## Examples + +```bash +core build sdk # Generate all +core build sdk --lang typescript # TypeScript only +core build sdk --spec ./api.yaml # Custom spec +core build sdk --dry-run # Preview +``` diff --git a/docs/build/cli/ci/changelog/example.md b/docs/build/cli/ci/changelog/example.md new file mode 100644 index 0000000..101cad7 --- /dev/null +++ b/docs/build/cli/ci/changelog/example.md @@ -0,0 +1,36 @@ +# CI Changelog Examples + +```bash +core ci changelog +``` + +## Output + +```markdown +## v1.2.0 + +### Features +- Add user authentication (#123) +- Support dark mode (#124) + +### Bug Fixes +- Fix memory leak in worker (#125) + +### Performance +- Optimize database queries (#126) +``` + +## Configuration + +`.core/release.yaml`: + +```yaml +changelog: + include: + - feat + - fix + - perf + exclude: + - chore + - docs +``` diff --git a/docs/build/cli/ci/changelog/index.md b/docs/build/cli/ci/changelog/index.md new file mode 100644 index 0000000..ffc0712 --- /dev/null +++ b/docs/build/cli/ci/changelog/index.md @@ -0,0 +1,28 @@ +# core ci changelog + +Generate changelog from conventional commits. + +## Usage + +```bash +core ci changelog +``` + +## Output + +Generates markdown changelog from git commits since last tag: + +```markdown +## v1.2.0 + +### Features +- Add user authentication (#123) +- Support dark mode (#124) + +### Bug Fixes +- Fix memory leak in worker (#125) +``` + +## Configuration + +See [configuration.md](../../../configuration.md) for changelog configuration options. diff --git a/docs/build/cli/ci/example.md b/docs/build/cli/ci/example.md new file mode 100644 index 0000000..faf4720 --- /dev/null +++ b/docs/build/cli/ci/example.md @@ -0,0 +1,90 @@ +# CI Examples + +## Quick Start + +```bash +# Build first +core build + +# Preview release +core ci + +# Publish +core ci --we-are-go-for-launch +``` + +## Configuration + +`.core/release.yaml`: + +```yaml +version: 1 + +project: + name: myapp + repository: host-uk/myapp + +publishers: + - type: github +``` + +## Publisher Examples + +### GitHub + Docker + +```yaml +publishers: + - type: github + + - type: docker + registry: ghcr.io + image: host-uk/myapp + platforms: + - linux/amd64 + - linux/arm64 + tags: + - latest + - "{{.Version}}" +``` + +### Full Stack (GitHub + npm + Homebrew) + +```yaml +publishers: + - type: github + + - type: npm + package: "@host-uk/myapp" + access: public + + - type: homebrew + tap: host-uk/homebrew-tap +``` + +### LinuxKit Image + +```yaml +publishers: + - type: linuxkit + config: .core/linuxkit/server.yml + formats: + - iso + - qcow2 + platforms: + - linux/amd64 + - linux/arm64 +``` + +## Changelog Configuration + +```yaml +changelog: + include: + - feat + - fix + - perf + exclude: + - chore + - docs + - test +``` diff --git a/docs/build/cli/ci/index.md b/docs/build/cli/ci/index.md new file mode 100644 index 0000000..ee2c759 --- /dev/null +++ b/docs/build/cli/ci/index.md @@ -0,0 +1,79 @@ +# core ci + +Publish releases to GitHub, Docker, npm, Homebrew, and more. + +**Safety:** Dry-run by default. Use `--we-are-go-for-launch` to actually publish. + +## Subcommands + +| Command | Description | +|---------|-------------| +| [init](init/) | Initialize release config | +| [changelog](changelog/) | Generate changelog | +| [version](version/) | Show determined version | + +## Usage + +```bash +core ci [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--we-are-go-for-launch` | Actually publish (default is dry-run) | +| `--version` | Override version | +| `--draft` | Create as draft release | +| `--prerelease` | Mark as prerelease | + +## Examples + +```bash +# Preview what would be published (safe) +core ci + +# Actually publish +core ci --we-are-go-for-launch + +# Publish as draft +core ci --we-are-go-for-launch --draft + +# Publish as prerelease +core ci --we-are-go-for-launch --prerelease +``` + +## Workflow + +Build and publish are **separated** to prevent accidents: + +```bash +# Step 1: Build artifacts +core build +core build sdk + +# Step 2: Preview (dry-run by default) +core ci + +# Step 3: Publish (explicit flag required) +core ci --we-are-go-for-launch +``` + +## Publishers + +See [Publisher Examples](example.md#publisher-examples) for configuration. + +| Type | Target | +|------|--------| +| `github` | GitHub Releases | +| `docker` | Container registries | +| `linuxkit` | LinuxKit images | +| `npm` | npm registry | +| `homebrew` | Homebrew tap | +| `scoop` | Scoop bucket | +| `aur` | Arch User Repository | +| `chocolatey` | Chocolatey | + +## Changelog + +Auto-generated from conventional commits. See [Changelog Configuration](example.md#changelog-configuration). diff --git a/docs/build/cli/ci/init/example.md b/docs/build/cli/ci/init/example.md new file mode 100644 index 0000000..8f76ab9 --- /dev/null +++ b/docs/build/cli/ci/init/example.md @@ -0,0 +1,17 @@ +# CI Init Examples + +```bash +core ci init +``` + +Creates `.core/release.yaml`: + +```yaml +version: 1 + +project: + name: myapp + +publishers: + - type: github +``` diff --git a/docs/build/cli/ci/init/index.md b/docs/build/cli/ci/init/index.md new file mode 100644 index 0000000..23ba068 --- /dev/null +++ b/docs/build/cli/ci/init/index.md @@ -0,0 +1,11 @@ +# core ci init + +Initialize release configuration. + +## Usage + +```bash +core ci init +``` + +Creates `.core/release.yaml` with default configuration. See [Configuration](../example.md#configuration) for output format. diff --git a/docs/build/cli/ci/version/example.md b/docs/build/cli/ci/version/example.md new file mode 100644 index 0000000..e669d65 --- /dev/null +++ b/docs/build/cli/ci/version/example.md @@ -0,0 +1,18 @@ +# CI Version Examples + +```bash +core ci version +``` + +## Output + +``` +v1.2.0 +``` + +## Version Resolution + +1. `--version` flag (if provided) +2. Git tag on HEAD +3. Latest git tag + increment +4. `v0.0.1` (no tags) diff --git a/docs/build/cli/ci/version/index.md b/docs/build/cli/ci/version/index.md new file mode 100644 index 0000000..7014a34 --- /dev/null +++ b/docs/build/cli/ci/version/index.md @@ -0,0 +1,21 @@ +# core ci version + +Show the determined release version. + +## Usage + +```bash +core ci version +``` + +## Output + +``` +v1.2.0 +``` + +Version is determined from: +1. `--version` flag (if provided) +2. Git tag on HEAD +3. Latest git tag + increment +4. `v0.0.1` (if no tags exist) diff --git a/docs/build/cli/dev/ci/index.md b/docs/build/cli/dev/ci/index.md new file mode 100644 index 0000000..0cf8442 --- /dev/null +++ b/docs/build/cli/dev/ci/index.md @@ -0,0 +1,61 @@ +# core dev ci + +Check CI status across all repositories. + +Fetches GitHub Actions workflow status for all repos. Shows latest run status for each repo. Requires the `gh` CLI to be installed and authenticated. + +## Usage + +```bash +core dev ci [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--branch` | Filter by branch (default: main) | +| `--failed` | Show only failed runs | + +## Examples + +```bash +# Check CI status for all repos +core dev ci + +# Check specific branch +core dev ci --branch develop + +# Show only failures +core dev ci --failed +``` + +## Output + +``` +core-php ✓ passing 2m ago +core-tenant ✓ passing 5m ago +core-admin ✗ failed 12m ago +core-api ⏳ running now +core-bio ✓ passing 1h ago +``` + +## Status Icons + +| Symbol | Meaning | +|--------|---------| +| `✓` | Passing | +| `✗` | Failed | +| `⏳` | Running | +| `-` | No runs | + +## Requirements + +- GitHub CLI (`gh`) must be installed +- Must be authenticated: `gh auth login` + +## See Also + +- [issues command](../issues/) - List open issues +- [reviews command](../reviews/) - List PRs needing review diff --git a/docs/build/cli/dev/commit/index.md b/docs/build/cli/dev/commit/index.md new file mode 100644 index 0000000..4258fb1 --- /dev/null +++ b/docs/build/cli/dev/commit/index.md @@ -0,0 +1,46 @@ +# core dev commit + +Claude-assisted commits across repositories. + +Uses Claude to create commits for dirty repos. Shows uncommitted changes and invokes Claude to generate commit messages. + +## Usage + +```bash +core dev commit [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--all` | Commit all dirty repos without prompting | + +## Examples + +```bash +# Interactive commit (prompts for each repo) +core dev commit + +# Commit all dirty repos automatically +core dev commit --all + +# Use specific registry +core dev commit --registry ~/projects/repos.yaml +``` + +## How It Works + +1. Scans all repositories for uncommitted changes +2. For each dirty repo: + - Shows the diff + - Invokes Claude to generate a commit message + - Creates the commit with `Co-Authored-By: Claude` +3. Reports success/failure for each repo + +## See Also + +- [health command](../health/) - Check repo status +- [push command](../push/) - Push commits after committing +- [work command](../work/) - Full workflow (status + commit + push) diff --git a/docs/build/cli/dev/example.md b/docs/build/cli/dev/example.md new file mode 100644 index 0000000..da75b5e --- /dev/null +++ b/docs/build/cli/dev/example.md @@ -0,0 +1,203 @@ +# Dev Examples + +## Multi-Repo Workflow + +```bash +# Quick status +core dev health + +# Detailed breakdown +core dev health --verbose + +# Full workflow +core dev work + +# Status only +core dev work --status + +# Commit and push +core dev work --commit + +# Commit dirty repos +core dev commit + +# Commit all without prompting +core dev commit --all + +# Push unpushed +core dev push + +# Push without confirmation +core dev push --force + +# Pull behind repos +core dev pull + +# Pull all repos +core dev pull --all +``` + +## GitHub Integration + +```bash +# Open issues +core dev issues + +# Filter by assignee +core dev issues --assignee @me + +# Limit results +core dev issues --limit 5 + +# PRs needing review +core dev reviews + +# All PRs including drafts +core dev reviews --all + +# Filter by author +core dev reviews --author username + +# CI status +core dev ci + +# Only failed runs +core dev ci --failed + +# Specific branch +core dev ci --branch develop +``` + +## Dependency Analysis + +```bash +# What depends on core-php? +core dev impact core-php +``` + +## Task Management + +```bash +# List tasks +core ai tasks + +# Filter by status and priority +core ai tasks --status pending --priority high + +# Filter by labels +core ai tasks --labels bug,urgent + +# Show task details +core ai task abc123 + +# Auto-select highest priority task +core ai task --auto + +# Claim a task +core ai task abc123 --claim + +# Update task status +core ai task:update abc123 --status in_progress + +# Add progress notes +core ai task:update abc123 --progress 50 --notes 'Halfway done' + +# Complete a task +core ai task:complete abc123 --output 'Feature implemented' + +# Mark as failed +core ai task:complete abc123 --failed --error 'Build failed' + +# Commit with task reference +core ai task:commit abc123 -m 'add user authentication' + +# Commit with scope and push +core ai task:commit abc123 -m 'fix login bug' --scope auth --push + +# Create PR for task +core ai task:pr abc123 + +# Create draft PR with labels +core ai task:pr abc123 --draft --labels 'enhancement,needs-review' +``` + +## Service API Management + +```bash +# Synchronize public service APIs +core dev sync + +# Or using the api command +core dev api sync +``` + +## Dev Environment + +```bash +# First time setup +core dev install +core dev boot + +# Open shell +core dev shell + +# Mount and serve +core dev serve + +# Run tests +core dev test + +# Sandboxed Claude +core dev claude +``` + +## Configuration + +### repos.yaml + +```yaml +org: host-uk +repos: + core-php: + type: package + description: Foundation framework + core-tenant: + type: package + depends: [core-php] +``` + +### ~/.core/config.yaml + +```yaml +version: 1 + +images: + source: auto # auto | github | registry | cdn + + cdn: + url: https://images.example.com/core-devops + + github: + repo: host-uk/core-images + + registry: + image: ghcr.io/host-uk/core-devops +``` + +### .core/test.yaml + +```yaml +version: 1 + +commands: + - name: unit + run: vendor/bin/pest --parallel + - name: types + run: vendor/bin/phpstan analyse + - name: lint + run: vendor/bin/pint --test + +env: + APP_ENV: testing + DB_CONNECTION: sqlite +``` diff --git a/docs/build/cli/dev/health/index.md b/docs/build/cli/dev/health/index.md new file mode 100644 index 0000000..d104689 --- /dev/null +++ b/docs/build/cli/dev/health/index.md @@ -0,0 +1,52 @@ +# core dev health + +Quick health check across all repositories. + +Shows a summary of repository health: total repos, dirty repos, unpushed commits, etc. + +## Usage + +```bash +core dev health [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--verbose` | Show detailed breakdown | + +## Examples + +```bash +# Quick health summary +core dev health + +# Detailed breakdown +core dev health --verbose + +# Use specific registry +core dev health --registry ~/projects/repos.yaml +``` + +## Output + +``` +18 repos │ 2 dirty │ 1 ahead │ all synced +``` + +With `--verbose`: + +``` +Repos: 18 +Dirty: 2 (core-php, core-admin) +Ahead: 1 (core-tenant) +Behind: 0 +Synced: ✓ +``` + +## See Also + +- [work command](../work/) - Full workflow (status + commit + push) +- [commit command](../commit/) - Claude-assisted commits diff --git a/docs/build/cli/dev/impact/index.md b/docs/build/cli/dev/impact/index.md new file mode 100644 index 0000000..ac96e04 --- /dev/null +++ b/docs/build/cli/dev/impact/index.md @@ -0,0 +1,65 @@ +# core dev impact + +Show impact of changing a repository. + +Analyses the dependency graph to show which repos would be affected by changes to the specified repo. + +## Usage + +```bash +core dev impact [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | + +## Examples + +```bash +# Show what depends on core-php +core dev impact core-php + +# Show what depends on core-tenant +core dev impact core-tenant +``` + +## Output + +``` +Impact of changes to core-php: + +Direct dependents (5): + core-tenant + core-admin + core-api + core-mcp + core-commerce + +Indirect dependents (12): + core-bio (via core-tenant) + core-social (via core-tenant) + core-analytics (via core-tenant) + core-notify (via core-tenant) + core-trust (via core-tenant) + core-support (via core-tenant) + core-content (via core-tenant) + core-developer (via core-tenant) + core-agentic (via core-mcp) + ... + +Total: 17 repos affected +``` + +## Use Cases + +- Before making breaking changes, see what needs updating +- Plan release order based on dependency graph +- Understand the ripple effect of changes + +## See Also + +- [health command](../health/) - Quick repo status +- [setup command](../../setup/) - Clone repos with dependencies diff --git a/docs/build/cli/dev/index.md b/docs/build/cli/dev/index.md new file mode 100644 index 0000000..56a5090 --- /dev/null +++ b/docs/build/cli/dev/index.md @@ -0,0 +1,388 @@ +# core dev + +Multi-repo workflow and portable development environment. + +## Multi-Repo Commands + +| Command | Description | +|---------|-------------| +| [work](work/) | Full workflow: status + commit + push | +| `health` | Quick health check across repos | +| `commit` | Claude-assisted commits | +| `push` | Push repos with unpushed commits | +| `pull` | Pull repos that are behind | +| `issues` | List open issues | +| `reviews` | List PRs needing review | +| `ci` | Check CI status | +| `impact` | Show dependency impact | +| `api` | Tools for managing service APIs | +| `sync` | Synchronize public service APIs | + +## Task Management Commands + +> **Note:** Task management commands have moved to [`core ai`](../ai/). + +| Command | Description | +|---------|-------------| +| [`ai tasks`](../ai/) | List available tasks from core-agentic | +| [`ai task`](../ai/) | Show task details or auto-select a task | +| [`ai task:update`](../ai/) | Update task status or progress | +| [`ai task:complete`](../ai/) | Mark a task as completed | +| [`ai task:commit`](../ai/) | Auto-commit changes with task reference | +| [`ai task:pr`](../ai/) | Create a pull request for a task | + +## Dev Environment Commands + +| Command | Description | +|---------|-------------| +| `install` | Download the core-devops image | +| `boot` | Start the environment | +| `stop` | Stop the environment | +| `status` | Show status | +| `shell` | Open shell | +| `serve` | Start dev server | +| `test` | Run tests | +| `claude` | Sandboxed Claude | +| `update` | Update image | + +--- + +## Dev Environment Overview + +Core DevOps provides a sandboxed, immutable development environment based on LinuxKit with 100+ embedded tools. + +## Quick Start + +```bash +# First time setup +core dev install +core dev boot + +# Open shell +core dev shell + +# Or mount current project and serve +core dev serve +``` + +## dev install + +Download the core-devops image for your platform. + +```bash +core dev install +``` + +Downloads the platform-specific dev environment image including Go, PHP, Node.js, Python, Docker, and Claude CLI. Downloads are cached at `~/.core/images/`. + +### Examples + +```bash +# Download image (auto-detects platform) +core dev install +``` + +## dev boot + +Start the development environment. + +```bash +core dev boot [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--memory` | Memory allocation in MB (default: 4096) | +| `--cpus` | Number of CPUs (default: 2) | +| `--fresh` | Stop existing and start fresh | + +### Examples + +```bash +# Start with defaults +core dev boot + +# More resources +core dev boot --memory 8192 --cpus 4 + +# Fresh start +core dev boot --fresh +``` + +## dev shell + +Open a shell in the running environment. + +```bash +core dev shell [flags] [-- command] +``` + +Uses SSH by default, or serial console with `--console`. + +### Flags + +| Flag | Description | +|------|-------------| +| `--console` | Use serial console instead of SSH | + +### Examples + +```bash +# SSH into environment +core dev shell + +# Serial console (for debugging) +core dev shell --console + +# Run a command +core dev shell -- ls -la +``` + +## dev serve + +Mount current directory and start the appropriate dev server. + +```bash +core dev serve [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--port` | Port to expose (default: 8000) | +| `--path` | Subdirectory to serve | + +### Auto-Detection + +| Project | Server Command | +|---------|---------------| +| Laravel (`artisan`) | `php artisan octane:start` | +| Node (`package.json` with `dev` script) | `npm run dev` | +| PHP (`composer.json`) | `frankenphp php-server` | +| Other | `python -m http.server` | + +### Examples + +```bash +# Auto-detect and serve +core dev serve + +# Custom port +core dev serve --port 3000 +``` + +## dev test + +Run tests inside the environment. + +```bash +core dev test [flags] [-- custom command] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--name` | Run named test command from `.core/test.yaml` | + +### Test Detection + +Core auto-detects the test framework or uses `.core/test.yaml`: + +1. `.core/test.yaml` - Custom config +2. `composer.json` → `composer test` +3. `package.json` → `npm test` +4. `go.mod` → `go test ./...` +5. `pytest.ini` → `pytest` +6. `Taskfile.yaml` → `task test` + +### Examples + +```bash +# Auto-detect and run tests +core dev test + +# Run named test from config +core dev test --name integration + +# Custom command +core dev test -- go test -v ./pkg/... +``` + +### Test Configuration + +Create `.core/test.yaml` for custom test setup - see [Configuration](example.md#configuration) for examples. + +## dev claude + +Start a sandboxed Claude session with your project mounted. + +```bash +core dev claude [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--model` | Model to use (`opus`, `sonnet`) | +| `--no-auth` | Don't forward any auth credentials | +| `--auth` | Selective auth forwarding (`gh`, `anthropic`, `ssh`, `git`) | + +### What Gets Forwarded + +By default, these are forwarded to the sandbox: +- `~/.anthropic/` or `ANTHROPIC_API_KEY` +- `~/.config/gh/` (GitHub CLI auth) +- SSH agent +- Git config (name, email) + +### Examples + +```bash +# Full auth forwarding (default) +core dev claude + +# Use Opus model +core dev claude --model opus + +# Clean sandbox +core dev claude --no-auth + +# Only GitHub and Anthropic auth +core dev claude --auth gh,anthropic +``` + +### Why Use This? + +- **Immutable base** - Reset anytime with `core dev boot --fresh` +- **Safe experimentation** - Claude can install packages, make mistakes +- **Host system untouched** - All changes stay in the sandbox +- **Real credentials** - Can still push code, create PRs +- **Full tooling** - 100+ tools available in the image + +## dev status + +Show the current state of the development environment. + +```bash +core dev status +``` + +Output includes: +- Running/stopped state +- Resource usage (CPU, memory) +- Exposed ports +- Mounted directories + +## dev update + +Check for and apply updates. + +```bash +core dev update [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--apply` | Download and apply the update | + +### Examples + +```bash +# Check for updates +core dev update + +# Apply available update +core dev update --apply +``` + +## Embedded Tools + +The core-devops image includes 100+ tools: + +| Category | Tools | +|----------|-------| +| **AI/LLM** | claude, gemini, aider, ollama, llm | +| **VCS** | git, gh, glab, lazygit, delta, git-lfs | +| **Runtimes** | frankenphp, node, bun, deno, go, python3, rustc | +| **Package Mgrs** | composer, npm, pnpm, yarn, pip, uv, cargo | +| **Build** | task, make, just, nx, turbo | +| **Linting** | pint, phpstan, prettier, eslint, biome, golangci-lint, ruff | +| **Testing** | phpunit, pest, vitest, playwright, k6 | +| **Infra** | docker, kubectl, k9s, helm, terraform, ansible | +| **Databases** | sqlite3, mysql, psql, redis-cli, mongosh, usql | +| **HTTP/Net** | curl, httpie, xh, websocat, grpcurl, mkcert, ngrok | +| **Data** | jq, yq, fx, gron, miller, dasel | +| **Security** | age, sops, cosign, trivy, trufflehog, vault | +| **Files** | fd, rg, fzf, bat, eza, tree, zoxide, broot | +| **Editors** | nvim, helix, micro | + +## Configuration + +Global config in `~/.core/config.yaml` - see [Configuration](example.md#configuration) for examples. + +## Image Storage + +Images are stored in `~/.core/images/`: + +``` +~/.core/ +├── config.yaml +└── images/ + ├── core-devops-darwin-arm64.qcow2 + ├── core-devops-linux-amd64.qcow2 + └── manifest.json +``` + +## Multi-Repo Commands + +See the [work](work/) page for detailed documentation on multi-repo commands. + +### dev ci + +Check GitHub Actions workflow status across all repos. + +```bash +core dev ci [flags] +``` + +#### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--branch` | Filter by branch (default: main) | +| `--failed` | Show only failed runs | + +Requires the `gh` CLI to be installed and authenticated. + +### dev api + +Tools for managing service APIs. + +```bash +core dev api sync +``` + +Synchronizes the public service APIs with their internal implementations. + +### dev sync + +Alias for `core dev api sync`. Synchronizes the public service APIs with their internal implementations. + +```bash +core dev sync +``` + +This command scans the `pkg` directory for services and ensures that the top-level public API for each service is in sync with its internal implementation. It automatically generates the necessary Go files with type aliases. + +## See Also + +- [work](work/) - Multi-repo workflow commands (`core dev work`, `core dev health`, etc.) +- [ai](../ai/) - Task management commands (`core ai tasks`, `core ai task`, etc.) diff --git a/docs/build/cli/dev/issues/index.md b/docs/build/cli/dev/issues/index.md new file mode 100644 index 0000000..36091eb --- /dev/null +++ b/docs/build/cli/dev/issues/index.md @@ -0,0 +1,57 @@ +# core dev issues + +List open issues across all repositories. + +Fetches open issues from GitHub for all repos in the registry. Requires the `gh` CLI to be installed and authenticated. + +## Usage + +```bash +core dev issues [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--assignee` | Filter by assignee (use `@me` for yourself) | +| `--limit` | Max issues per repo (default 10) | + +## Examples + +```bash +# List all open issues +core dev issues + +# Show issues assigned to you +core dev issues --assignee @me + +# Limit to 5 issues per repo +core dev issues --limit 5 + +# Filter by specific assignee +core dev issues --assignee username +``` + +## Output + +``` +core-php (3 issues) + #42 Add retry logic to HTTP client bug + #38 Update documentation for v2 API docs + #35 Support custom serializers enhancement + +core-tenant (1 issue) + #12 Workspace isolation bug bug, critical +``` + +## Requirements + +- GitHub CLI (`gh`) must be installed +- Must be authenticated: `gh auth login` + +## See Also + +- [reviews command](../reviews/) - List PRs needing review +- [ci command](../ci/) - Check CI status diff --git a/docs/build/cli/dev/pull/index.md b/docs/build/cli/dev/pull/index.md new file mode 100644 index 0000000..1f6f3df --- /dev/null +++ b/docs/build/cli/dev/pull/index.md @@ -0,0 +1,47 @@ +# core dev pull + +Pull updates across all repositories. + +Pulls updates for all repos. By default only pulls repos that are behind. Use `--all` to pull all repos. + +## Usage + +```bash +core dev pull [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--all` | Pull all repos, not just those behind | + +## Examples + +```bash +# Pull only repos that are behind +core dev pull + +# Pull all repos +core dev pull --all + +# Use specific registry +core dev pull --registry ~/projects/repos.yaml +``` + +## Output + +``` +Pulling 2 repo(s) that are behind: + ✓ core-php (3 commits) + ✓ core-tenant (1 commit) + +Done: 2 pulled +``` + +## See Also + +- [push command](../push/) - Push local commits +- [health command](../health/) - Check sync status +- [work command](../work/) - Full workflow diff --git a/docs/build/cli/dev/push/index.md b/docs/build/cli/dev/push/index.md new file mode 100644 index 0000000..0c11195 --- /dev/null +++ b/docs/build/cli/dev/push/index.md @@ -0,0 +1,52 @@ +# core dev push + +Push commits across all repositories. + +Pushes unpushed commits for all repos. Shows repos with commits to push and confirms before pushing. + +## Usage + +```bash +core dev push [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--force` | Skip confirmation prompt | + +## Examples + +```bash +# Push with confirmation +core dev push + +# Push without confirmation +core dev push --force + +# Use specific registry +core dev push --registry ~/projects/repos.yaml +``` + +## Output + +``` +3 repo(s) with unpushed commits: + core-php: 2 commit(s) + core-admin: 1 commit(s) + core-tenant: 1 commit(s) + +Push all? [y/N] y + + ✓ core-php + ✓ core-admin + ✓ core-tenant +``` + +## See Also + +- [commit command](../commit/) - Create commits before pushing +- [pull command](../pull/) - Pull updates from remote +- [work command](../work/) - Full workflow (status + commit + push) diff --git a/docs/build/cli/dev/reviews/index.md b/docs/build/cli/dev/reviews/index.md new file mode 100644 index 0000000..44c09ad --- /dev/null +++ b/docs/build/cli/dev/reviews/index.md @@ -0,0 +1,61 @@ +# core dev reviews + +List PRs needing review across all repositories. + +Fetches open PRs from GitHub for all repos in the registry. Shows review status (approved, changes requested, pending). Requires the `gh` CLI to be installed and authenticated. + +## Usage + +```bash +core dev reviews [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--all` | Show all PRs including drafts | +| `--author` | Filter by PR author | + +## Examples + +```bash +# List PRs needing review +core dev reviews + +# Include draft PRs +core dev reviews --all + +# Filter by author +core dev reviews --author username +``` + +## Output + +``` +core-php (2 PRs) + #45 feat: Add caching layer ✓ approved @alice + #43 fix: Memory leak in worker ⏳ pending @bob + +core-admin (1 PR) + #28 refactor: Extract components ✗ changes @charlie +``` + +## Review Status + +| Symbol | Meaning | +|--------|---------| +| `✓` | Approved | +| `⏳` | Pending review | +| `✗` | Changes requested | + +## Requirements + +- GitHub CLI (`gh`) must be installed +- Must be authenticated: `gh auth login` + +## See Also + +- [issues command](../issues/) - List open issues +- [ci command](../ci/) - Check CI status diff --git a/docs/build/cli/dev/work/example.md b/docs/build/cli/dev/work/example.md new file mode 100644 index 0000000..74db3fb --- /dev/null +++ b/docs/build/cli/dev/work/example.md @@ -0,0 +1,33 @@ +# Dev Work Examples + +```bash +# Full workflow: status → commit → push +core dev work + +# Status only +core dev work --status +``` + +## Output + +``` +┌─────────────┬────────┬──────────┬─────────┐ +│ Repo │ Branch │ Status │ Behind │ +├─────────────┼────────┼──────────┼─────────┤ +│ core-php │ main │ clean │ 0 │ +│ core-tenant │ main │ 2 files │ 0 │ +│ core-admin │ dev │ clean │ 3 │ +└─────────────┴────────┴──────────┴─────────┘ +``` + +## Registry + +```yaml +repos: + - name: core + path: ./core + url: https://github.com/host-uk/core + - name: core-php + path: ./core-php + url: https://github.com/host-uk/core-php +``` diff --git a/docs/build/cli/dev/work/index.md b/docs/build/cli/dev/work/index.md new file mode 100644 index 0000000..454fe22 --- /dev/null +++ b/docs/build/cli/dev/work/index.md @@ -0,0 +1,293 @@ +# core dev work + +Multi-repo git operations for managing the host-uk organization. + +## Overview + +The `core dev work` command and related subcommands help manage multiple repositories in the host-uk ecosystem simultaneously. + +## Commands + +| Command | Description | +|---------|-------------| +| `core dev work` | Full workflow: status + commit + push | +| `core dev work --status` | Status table only | +| `core dev work --commit` | Use Claude to commit dirty repos | +| `core dev health` | Quick health check across all repos | +| `core dev commit` | Claude-assisted commits across repos | +| `core dev push` | Push commits across all repos | +| `core dev pull` | Pull updates across all repos | +| `core dev issues` | List open issues across all repos | +| `core dev reviews` | List PRs needing review | +| `core dev ci` | Check CI status across all repos | +| `core dev impact` | Show impact of changing a repo | + +## core dev work + +Manage git status, commits, and pushes across multiple repositories. + +```bash +core dev work [flags] +``` + +Reads `repos.yaml` to discover repositories and their relationships. Shows status, optionally commits with Claude, and pushes changes. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--status` | Show status only, don't push | +| `--commit` | Use Claude to commit dirty repos before pushing | + +### Examples + +```bash +# Full workflow +core dev work + +# Status only +core dev work --status + +# Commit and push +core dev work --commit +``` + +## core dev health + +Quick health check showing summary of repository health across all repos. + +```bash +core dev health [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--verbose` | Show detailed breakdown | + +Output shows: +- Total repos +- Dirty repos +- Unpushed commits +- Repos behind remote + +### Examples + +```bash +# Quick summary +core dev health + +# Detailed breakdown +core dev health --verbose +``` + +## core dev issues + +List open issues across all repositories. + +```bash +core dev issues [flags] +``` + +Fetches open issues from GitHub for all repos in the registry. Requires the `gh` CLI to be installed and authenticated. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--assignee` | Filter by assignee (use `@me` for yourself) | +| `--limit` | Max issues per repo (default: 10) | + +### Examples + +```bash +# List all open issues +core dev issues + +# Filter by assignee +core dev issues --assignee @me + +# Limit results +core dev issues --limit 5 +``` + +## core dev reviews + +List pull requests needing review across all repos. + +```bash +core dev reviews [flags] +``` + +Fetches open PRs from GitHub for all repos in the registry. Shows review status (approved, changes requested, pending). Requires the `gh` CLI to be installed and authenticated. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--all` | Show all PRs including drafts | +| `--author` | Filter by PR author | + +### Examples + +```bash +# List PRs needing review +core dev reviews + +# Show all PRs including drafts +core dev reviews --all + +# Filter by author +core dev reviews --author username +``` + +## core dev commit + +Create commits across repos with Claude assistance. + +```bash +core dev commit [flags] +``` + +Uses Claude to create commits for dirty repos. Shows uncommitted changes and invokes Claude to generate commit messages. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--all` | Commit all dirty repos without prompting | + +### Examples + +```bash +# Commit with prompts +core dev commit + +# Commit all automatically +core dev commit --all +``` + +## core dev push + +Push commits across all repos. + +```bash +core dev push [flags] +``` + +Pushes unpushed commits for all repos. Shows repos with commits to push and confirms before pushing. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--force` | Skip confirmation prompt | + +### Examples + +```bash +# Push with confirmation +core dev push + +# Skip confirmation +core dev push --force +``` + +## core dev pull + +Pull updates across all repos. + +```bash +core dev pull [flags] +``` + +Pulls updates for all repos. By default only pulls repos that are behind. Use `--all` to pull all repos. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--all` | Pull all repos, not just those behind | + +### Examples + +```bash +# Pull repos that are behind +core dev pull + +# Pull all repos +core dev pull --all +``` + +## core dev ci + +Check GitHub Actions workflow status across all repos. + +```bash +core dev ci [flags] +``` + +Fetches GitHub Actions workflow status for all repos. Shows latest run status for each repo. Requires the `gh` CLI to be installed and authenticated. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | +| `--branch` | Filter by branch (default: main) | +| `--failed` | Show only failed runs | + +### Examples + +```bash +# Show CI status for all repos +core dev ci + +# Show only failed runs +core dev ci --failed + +# Check specific branch +core dev ci --branch develop +``` + +## core dev impact + +Show the impact of changing a repository. + +```bash +core dev impact [flags] +``` + +Analyzes the dependency graph to show which repos would be affected by changes to the specified repo. + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to `repos.yaml` (auto-detected if not specified) | + +### Examples + +```bash +# Show impact of changing core-php +core dev impact core-php +``` + +## Registry + +These commands use `repos.yaml` to know which repos to manage. See [repos.yaml](../../../configuration.md#reposyaml) for format. + +Use `core setup` to clone all repos from the registry. + +## See Also + +- [setup command](../../setup/) - Clone repos from registry +- [search command](../../pkg/search/) - Find and install repos diff --git a/docs/build/cli/docs/example.md b/docs/build/cli/docs/example.md new file mode 100644 index 0000000..7729970 --- /dev/null +++ b/docs/build/cli/docs/example.md @@ -0,0 +1,14 @@ +# Docs Examples + +## List + +```bash +core docs list +``` + +## Sync + +```bash +core docs sync +core docs sync --output ./docs +``` diff --git a/docs/build/cli/docs/index.md b/docs/build/cli/docs/index.md new file mode 100644 index 0000000..d73ebf0 --- /dev/null +++ b/docs/build/cli/docs/index.md @@ -0,0 +1,110 @@ +# core docs + +Documentation management across repositories. + +## Usage + +```bash +core docs [flags] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `list` | List documentation across repos | +| `sync` | Sync documentation to output directory | + +## docs list + +Show documentation coverage across all repos. + +```bash +core docs list [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml | + +### Output + +``` +Repo README CLAUDE CHANGELOG docs/ +────────────────────────────────────────────────────────────────────── +core ✓ ✓ — 12 files +core-php ✓ ✓ ✓ 8 files +core-images ✓ — — — + +Coverage: 3 with docs, 0 without +``` + +## docs sync + +Sync documentation from all repos to an output directory. + +```bash +core docs sync [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml | +| `--output` | Output directory (default: ./docs-build) | +| `--dry-run` | Show what would be synced | + +### Output Structure + +``` +docs-build/ +└── packages/ + ├── core/ + │ ├── index.md # from README.md + │ ├── claude.md # from CLAUDE.md + │ ├── changelog.md # from CHANGELOG.md + │ ├── build.md # from docs/build.md + │ └── ... + └── core-php/ + ├── index.md + └── ... +``` + +### Example + +```bash +# Preview what will be synced +core docs sync --dry-run + +# Sync to default output +core docs sync + +# Sync to custom directory +core docs sync --output ./site/content +``` + +## What Gets Synced + +For each repo, the following files are collected: + +| Source | Destination | +|--------|-------------| +| `README.md` | `index.md` | +| `CLAUDE.md` | `claude.md` | +| `CHANGELOG.md` | `changelog.md` | +| `docs/*.md` | `*.md` | + +## Integration with core.help + +The synced docs are used to build https://core.help: + +1. Run `core docs sync --output ../core-php/docs/packages` +2. VitePress builds the combined documentation +3. Deploy to core.help + +## See Also + +- [Configuration](../../configuration.md) - Project configuration diff --git a/docs/build/cli/doctor/example.md b/docs/build/cli/doctor/example.md new file mode 100644 index 0000000..ba94d71 --- /dev/null +++ b/docs/build/cli/doctor/example.md @@ -0,0 +1,20 @@ +# Doctor Examples + +```bash +core doctor +``` + +## Output + +``` +✓ go 1.25.0 +✓ git 2.43.0 +✓ gh 2.40.0 +✓ docker 24.0.7 +✓ task 3.30.0 +✓ golangci-lint 1.55.0 +✗ wails (not installed) +✓ php 8.3.0 +✓ composer 2.6.0 +✓ node 20.10.0 +``` diff --git a/docs/build/cli/doctor/index.md b/docs/build/cli/doctor/index.md new file mode 100644 index 0000000..02cc44d --- /dev/null +++ b/docs/build/cli/doctor/index.md @@ -0,0 +1,81 @@ +# core doctor + +Check your development environment for required tools and configuration. + +## Usage + +```bash +core doctor [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--verbose` | Show detailed version information | + +## What It Checks + +### Required Tools + +| Tool | Purpose | +|------|---------| +| `git` | Version control | +| `go` | Go compiler | +| `gh` | GitHub CLI | + +### Optional Tools + +| Tool | Purpose | +|------|---------| +| `node` | Node.js runtime | +| `docker` | Container runtime | +| `wails` | Desktop app framework | +| `qemu` | VM runtime for LinuxKit | +| `gpg` | Code signing | +| `codesign` | macOS signing (macOS only) | + +### Configuration + +- Git user name and email +- GitHub CLI authentication +- Go workspace setup + +## Output + +``` +Core Doctor +=========== + +Required: + [OK] git 2.43.0 + [OK] go 1.23.0 + [OK] gh 2.40.0 + +Optional: + [OK] node 20.10.0 + [OK] docker 24.0.7 + [--] wails (not installed) + [OK] qemu 8.2.0 + [OK] gpg 2.4.3 + [OK] codesign (available) + +Configuration: + [OK] git user.name: Your Name + [OK] git user.email: you@example.com + [OK] gh auth status: Logged in + +All checks passed! +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | All required checks passed | +| 1 | One or more required checks failed | + +## See Also + +- [setup command](../setup/) - Clone repos from registry +- [dev](../dev/) - Development environment diff --git a/docs/build/cli/go/cov/example.md b/docs/build/cli/go/cov/example.md new file mode 100644 index 0000000..4fdc6c2 --- /dev/null +++ b/docs/build/cli/go/cov/example.md @@ -0,0 +1,18 @@ +# Go Coverage Examples + +```bash +# Summary +core go cov + +# HTML report +core go cov --html + +# Open in browser +core go cov --open + +# Fail if below threshold +core go cov --threshold 80 + +# Specific package +core go cov --pkg ./pkg/release +``` diff --git a/docs/build/cli/go/cov/index.md b/docs/build/cli/go/cov/index.md new file mode 100644 index 0000000..3adeca3 --- /dev/null +++ b/docs/build/cli/go/cov/index.md @@ -0,0 +1,28 @@ +# core go cov + +Generate coverage report with thresholds. + +## Usage + +```bash +core go cov [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--pkg` | Package to test (default: `./...`) | +| `--html` | Generate HTML coverage report | +| `--open` | Generate and open HTML report in browser | +| `--threshold` | Minimum coverage percentage (exit 1 if below) | + +## Examples + +```bash +core go cov # Summary +core go cov --html # HTML report +core go cov --open # Open in browser +core go cov --threshold 80 # Fail if < 80% +core go cov --pkg ./pkg/release # Specific package +``` diff --git a/docs/build/cli/go/example.md b/docs/build/cli/go/example.md new file mode 100644 index 0000000..51ad71a --- /dev/null +++ b/docs/build/cli/go/example.md @@ -0,0 +1,89 @@ +# Go Examples + +## Testing + +```bash +# Run all tests +core go test + +# Specific package +core go test --pkg ./pkg/core + +# Specific test +core go test --run TestHash + +# With coverage +core go test --coverage + +# Race detection +core go test --race +``` + +## Coverage + +```bash +# Summary +core go cov + +# HTML report +core go cov --html + +# Open in browser +core go cov --open + +# Fail if below threshold +core go cov --threshold 80 +``` + +## Formatting + +```bash +# Check +core go fmt + +# Fix +core go fmt --fix + +# Show diff +core go fmt --diff +``` + +## Linting + +```bash +# Check +core go lint + +# Auto-fix +core go lint --fix +``` + +## Installing + +```bash +# Auto-detect cmd/ +core go install + +# Specific path +core go install ./cmd/myapp + +# Pure Go (no CGO) +core go install --no-cgo +``` + +## Module Management + +```bash +core go mod tidy +core go mod download +core go mod verify +core go mod graph +``` + +## Workspace + +```bash +core go work sync +core go work init +core go work use ./pkg/mymodule +``` diff --git a/docs/build/cli/go/fmt/example.md b/docs/build/cli/go/fmt/example.md new file mode 100644 index 0000000..40233e0 --- /dev/null +++ b/docs/build/cli/go/fmt/example.md @@ -0,0 +1,12 @@ +# Go Format Examples + +```bash +# Check only +core go fmt + +# Apply fixes +core go fmt --fix + +# Show diff +core go fmt --diff +``` diff --git a/docs/build/cli/go/fmt/index.md b/docs/build/cli/go/fmt/index.md new file mode 100644 index 0000000..fe6113e --- /dev/null +++ b/docs/build/cli/go/fmt/index.md @@ -0,0 +1,25 @@ +# core go fmt + +Format Go code using goimports or gofmt. + +## Usage + +```bash +core go fmt [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--fix` | Fix formatting in place | +| `--diff` | Show diff of changes | +| `--check` | Check only, exit 1 if not formatted | + +## Examples + +```bash +core go fmt # Check formatting +core go fmt --fix # Fix formatting +core go fmt --diff # Show diff +``` diff --git a/docs/build/cli/go/index.md b/docs/build/cli/go/index.md new file mode 100644 index 0000000..981953c --- /dev/null +++ b/docs/build/cli/go/index.md @@ -0,0 +1,15 @@ +# core go + +Go development tools with enhanced output and environment setup. + +## Subcommands + +| Command | Description | +|---------|-------------| +| [test](test/) | Run tests with coverage | +| [cov](cov/) | Run tests with coverage report | +| [fmt](fmt/) | Format Go code | +| [lint](lint/) | Run golangci-lint | +| [install](install/) | Install Go binary | +| [mod](mod/) | Module management | +| [work](work/) | Workspace management | diff --git a/docs/build/cli/go/install/example.md b/docs/build/cli/go/install/example.md new file mode 100644 index 0000000..bba88cd --- /dev/null +++ b/docs/build/cli/go/install/example.md @@ -0,0 +1,15 @@ +# Go Install Examples + +```bash +# Auto-detect cmd/ +core go install + +# Specific path +core go install ./cmd/myapp + +# Pure Go (no CGO) +core go install --no-cgo + +# Verbose +core go install -v +``` diff --git a/docs/build/cli/go/install/index.md b/docs/build/cli/go/install/index.md new file mode 100644 index 0000000..e7bd109 --- /dev/null +++ b/docs/build/cli/go/install/index.md @@ -0,0 +1,25 @@ +# core go install + +Install Go binary with auto-detection. + +## Usage + +```bash +core go install [path] [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--no-cgo` | Disable CGO | +| `-v` | Verbose | + +## Examples + +```bash +core go install # Install current module +core go install ./cmd/core # Install specific path +core go install --no-cgo # Pure Go (no C dependencies) +core go install -v # Verbose output +``` diff --git a/docs/build/cli/go/lint/example.md b/docs/build/cli/go/lint/example.md new file mode 100644 index 0000000..56b46d4 --- /dev/null +++ b/docs/build/cli/go/lint/example.md @@ -0,0 +1,22 @@ +# Go Lint Examples + +```bash +# Check +core go lint + +# Auto-fix +core go lint --fix +``` + +## Configuration + +`.golangci.yml`: + +```yaml +linters: + enable: + - gofmt + - govet + - errcheck + - staticcheck +``` diff --git a/docs/build/cli/go/lint/index.md b/docs/build/cli/go/lint/index.md new file mode 100644 index 0000000..5f9e804 --- /dev/null +++ b/docs/build/cli/go/lint/index.md @@ -0,0 +1,22 @@ +# core go lint + +Run golangci-lint. + +## Usage + +```bash +core go lint [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--fix` | Fix issues automatically | + +## Examples + +```bash +core go lint # Check +core go lint --fix # Auto-fix +``` diff --git a/docs/build/cli/go/mod/download/index.md b/docs/build/cli/go/mod/download/index.md new file mode 100644 index 0000000..240ef6d --- /dev/null +++ b/docs/build/cli/go/mod/download/index.md @@ -0,0 +1,29 @@ +# core go mod download + +Download modules to local cache. + +Wrapper around `go mod download`. Downloads all dependencies to the module cache. + +## Usage + +```bash +core go mod download +``` + +## What It Does + +- Downloads all modules in go.mod to `$GOPATH/pkg/mod` +- Useful for pre-populating cache for offline builds +- Validates checksums against go.sum + +## Examples + +```bash +# Download all dependencies +core go mod download +``` + +## See Also + +- [tidy](../tidy/) - Clean up go.mod +- [verify](../verify/) - Verify checksums diff --git a/docs/build/cli/go/mod/example.md b/docs/build/cli/go/mod/example.md new file mode 100644 index 0000000..57d2e66 --- /dev/null +++ b/docs/build/cli/go/mod/example.md @@ -0,0 +1,15 @@ +# Go Module Examples + +```bash +# Tidy +core go mod tidy + +# Download +core go mod download + +# Verify +core go mod verify + +# Graph +core go mod graph +``` diff --git a/docs/build/cli/go/mod/graph/index.md b/docs/build/cli/go/mod/graph/index.md new file mode 100644 index 0000000..2aa2619 --- /dev/null +++ b/docs/build/cli/go/mod/graph/index.md @@ -0,0 +1,44 @@ +# core go mod graph + +Print module dependency graph. + +Wrapper around `go mod graph`. Outputs the module dependency graph in text form. + +## Usage + +```bash +core go mod graph +``` + +## What It Does + +- Prints module dependencies as pairs +- Each line shows: `module@version dependency@version` +- Useful for understanding dependency relationships + +## Examples + +```bash +# Print dependency graph +core go mod graph + +# Find who depends on a specific module +core go mod graph | grep "some/module" + +# Visualise with graphviz +core go mod graph | dot -Tpng -o deps.png +``` + +## Output + +``` +github.com/host-uk/core github.com/stretchr/testify@v1.11.1 +github.com/stretchr/testify@v1.11.1 github.com/davecgh/go-spew@v1.1.2 +github.com/stretchr/testify@v1.11.1 github.com/pmezard/go-difflib@v1.0.1 +... +``` + +## See Also + +- [tidy](../tidy/) - Clean up go.mod +- [dev impact](../../../dev/impact/) - Show repo dependency impact diff --git a/docs/build/cli/go/mod/index.md b/docs/build/cli/go/mod/index.md new file mode 100644 index 0000000..ee8e46e --- /dev/null +++ b/docs/build/cli/go/mod/index.md @@ -0,0 +1,21 @@ +# core go mod + +Module management. + +## Subcommands + +| Command | Description | +|---------|-------------| +| `tidy` | Add missing and remove unused modules | +| `download` | Download modules to local cache | +| `verify` | Verify dependencies | +| `graph` | Print module dependency graph | + +## Examples + +```bash +core go mod tidy +core go mod download +core go mod verify +core go mod graph +``` diff --git a/docs/build/cli/go/mod/tidy/index.md b/docs/build/cli/go/mod/tidy/index.md new file mode 100644 index 0000000..684b07e --- /dev/null +++ b/docs/build/cli/go/mod/tidy/index.md @@ -0,0 +1,29 @@ +# core go mod tidy + +Add missing and remove unused modules. + +Wrapper around `go mod tidy`. Ensures go.mod and go.sum are in sync with the source code. + +## Usage + +```bash +core go mod tidy +``` + +## What It Does + +- Adds missing module requirements +- Removes unused module requirements +- Updates go.sum with checksums + +## Examples + +```bash +# Tidy the current module +core go mod tidy +``` + +## See Also + +- [download](../download/) - Download modules +- [verify](../verify/) - Verify dependencies diff --git a/docs/build/cli/go/mod/verify/index.md b/docs/build/cli/go/mod/verify/index.md new file mode 100644 index 0000000..e01dc2a --- /dev/null +++ b/docs/build/cli/go/mod/verify/index.md @@ -0,0 +1,41 @@ +# core go mod verify + +Verify dependencies have not been modified. + +Wrapper around `go mod verify`. Checks that dependencies in the module cache match their checksums in go.sum. + +## Usage + +```bash +core go mod verify +``` + +## What It Does + +- Verifies each module in the cache +- Compares against go.sum checksums +- Reports any tampering or corruption + +## Examples + +```bash +# Verify all dependencies +core go mod verify +``` + +## Output + +``` +all modules verified +``` + +Or if verification fails: + +``` +github.com/example/pkg v1.2.3: dir has been modified +``` + +## See Also + +- [download](../download/) - Download modules +- [tidy](../tidy/) - Clean up go.mod diff --git a/docs/build/cli/go/test/example.md b/docs/build/cli/go/test/example.md new file mode 100644 index 0000000..85ff1b5 --- /dev/null +++ b/docs/build/cli/go/test/example.md @@ -0,0 +1,27 @@ +# Go Test Examples + +```bash +# All tests +core go test + +# Specific package +core go test --pkg ./pkg/core + +# Specific test +core go test --run TestHash + +# With coverage +core go test --coverage + +# Race detection +core go test --race + +# Short tests only +core go test --short + +# Verbose +core go test -v + +# JSON output (CI) +core go test --json +``` diff --git a/docs/build/cli/go/test/index.md b/docs/build/cli/go/test/index.md new file mode 100644 index 0000000..8b54524 --- /dev/null +++ b/docs/build/cli/go/test/index.md @@ -0,0 +1,31 @@ +# core go test + +Run Go tests with coverage and filtered output. + +## Usage + +```bash +core go test [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--pkg` | Package to test (default: `./...`) | +| `--run` | Run only tests matching regexp | +| `--short` | Run only short tests | +| `--race` | Enable race detector | +| `--coverage` | Show detailed per-package coverage | +| `--json` | Output JSON results | +| `-v` | Verbose output | + +## Examples + +```bash +core go test # All tests +core go test --pkg ./pkg/core # Specific package +core go test --run TestHash # Specific test +core go test --coverage # With coverage +core go test --race # Race detection +``` diff --git a/docs/build/cli/go/work/index.md b/docs/build/cli/go/work/index.md new file mode 100644 index 0000000..4022507 --- /dev/null +++ b/docs/build/cli/go/work/index.md @@ -0,0 +1,19 @@ +# core go work + +Go workspace management commands. + +## Subcommands + +| Command | Description | +|---------|-------------| +| `sync` | Sync go.work with modules | +| `init` | Initialize go.work | +| `use` | Add module to workspace | + +## Examples + +```bash +core go work sync # Sync workspace +core go work init # Initialize workspace +core go work use ./pkg/mymodule # Add module to workspace +``` diff --git a/docs/build/cli/go/work/init/index.md b/docs/build/cli/go/work/init/index.md new file mode 100644 index 0000000..6527324 --- /dev/null +++ b/docs/build/cli/go/work/init/index.md @@ -0,0 +1,40 @@ +# core go work init + +Initialize a Go workspace. + +Wrapper around `go work init`. Creates a new go.work file in the current directory. + +## Usage + +```bash +core go work init +``` + +## What It Does + +- Creates a go.work file +- Automatically adds current module if go.mod exists +- Enables multi-module development + +## Examples + +```bash +# Initialize workspace +core go work init + +# Then add more modules +core go work use ./pkg/mymodule +``` + +## Generated File + +```go +go 1.25 + +use . +``` + +## See Also + +- [use](../use/) - Add module to workspace +- [sync](../sync/) - Sync workspace diff --git a/docs/build/cli/go/work/sync/index.md b/docs/build/cli/go/work/sync/index.md new file mode 100644 index 0000000..38caed1 --- /dev/null +++ b/docs/build/cli/go/work/sync/index.md @@ -0,0 +1,35 @@ +# core go work sync + +Sync go.work with modules. + +Wrapper around `go work sync`. Synchronises the workspace's build list back to the workspace modules. + +## Usage + +```bash +core go work sync +``` + +## What It Does + +- Updates each module's go.mod to match the workspace build list +- Ensures all modules use compatible dependency versions +- Run after adding new modules or updating dependencies + +## Examples + +```bash +# Sync workspace +core go work sync +``` + +## When To Use + +- After running `go get` to update a dependency +- After adding a new module with `core go work use` +- When modules have conflicting dependency versions + +## See Also + +- [init](../init/) - Initialize workspace +- [use](../use/) - Add module to workspace diff --git a/docs/build/cli/go/work/use/index.md b/docs/build/cli/go/work/use/index.md new file mode 100644 index 0000000..25e0cab --- /dev/null +++ b/docs/build/cli/go/work/use/index.md @@ -0,0 +1,46 @@ +# core go work use + +Add module to workspace. + +Wrapper around `go work use`. Adds one or more modules to the go.work file. + +## Usage + +```bash +core go work use [paths...] +``` + +## What It Does + +- Adds specified module paths to go.work +- Auto-discovers modules if no paths given +- Enables developing multiple modules together + +## Examples + +```bash +# Add a specific module +core go work use ./pkg/mymodule + +# Add multiple modules +core go work use ./pkg/one ./pkg/two + +# Auto-discover and add all modules +core go work use +``` + +## Auto-Discovery + +When called without arguments, scans for go.mod files and adds all found modules: + +```bash +core go work use +# Added ./pkg/build +# Added ./pkg/repos +# Added ./cmd/core +``` + +## See Also + +- [init](../init/) - Initialize workspace +- [sync](../sync/) - Sync workspace diff --git a/docs/build/cli/index.md b/docs/build/cli/index.md new file mode 100644 index 0000000..9aeee02 --- /dev/null +++ b/docs/build/cli/index.md @@ -0,0 +1,31 @@ +# Core CLI + +Unified interface for Go/PHP development, multi-repo management, and deployment. + +## Commands + +| Command | Description | +|---------|-------------| +| [ai](ai/) | AI agent task management and Claude integration | +| [go](go/) | Go development tools | +| [php](php/) | Laravel/PHP development tools | +| [build](build/) | Build projects | +| [ci](ci/) | Publish releases | +| [sdk](sdk/) | SDK validation and compatibility | +| [dev](dev/) | Multi-repo workflow + dev environment | +| [pkg](pkg/) | Package management | +| [vm](vm/) | LinuxKit VM management | +| [docs](docs/) | Documentation management | +| [setup](setup/) | Clone repos from registry | +| [doctor](doctor/) | Check environment | +| [test](test/) | Run Go tests with coverage | + +## Installation + +```bash +go install github.com/host-uk/core/cmd/core@latest +``` + +Verify: `core doctor` + +See [Getting Started](/build/go/getting-started) for all installation options. diff --git a/docs/build/cli/php/example.md b/docs/build/cli/php/example.md new file mode 100644 index 0000000..96e1600 --- /dev/null +++ b/docs/build/cli/php/example.md @@ -0,0 +1,111 @@ +# PHP Examples + +## Development + +```bash +# Start all services +core php dev + +# With HTTPS +core php dev --https + +# Skip services +core php dev --no-vite --no-horizon +``` + +## Testing + +```bash +# Run all +core php test + +# Parallel +core php test --parallel + +# With coverage +core php test --coverage + +# Filter +core php test --filter UserTest +``` + +## Code Quality + +```bash +# Format +core php fmt --fix + +# Static analysis +core php analyse --level 9 +``` + +## Deployment + +```bash +# Production +core php deploy + +# Staging +core php deploy --staging + +# Wait for completion +core php deploy --wait + +# Check status +core php deploy:status + +# Rollback +core php deploy:rollback +``` + +## Configuration + +### .env + +```env +COOLIFY_URL=https://coolify.example.com +COOLIFY_TOKEN=your-api-token +COOLIFY_APP_ID=production-app-id +COOLIFY_STAGING_APP_ID=staging-app-id +``` + +### .core/php.yaml + +```yaml +version: 1 + +dev: + domain: myapp.test + ssl: true + services: + - frankenphp + - vite + - horizon + - reverb + - redis + +deploy: + coolify: + server: https://coolify.example.com + project: my-project +``` + +## Package Linking + +```bash +# Link local packages +core php packages link ../my-package + +# Update linked +core php packages update + +# Unlink +core php packages unlink my-package +``` + +## SSL Setup + +```bash +core php ssl +core php ssl --domain myapp.test +``` diff --git a/docs/build/cli/php/index.md b/docs/build/cli/php/index.md new file mode 100644 index 0000000..83ad596 --- /dev/null +++ b/docs/build/cli/php/index.md @@ -0,0 +1,413 @@ +# core php + +Laravel/PHP development tools with FrankenPHP. + +## Commands + +### Development + +| Command | Description | +|---------|-------------| +| [`dev`](#php-dev) | Start development environment | +| [`logs`](#php-logs) | View service logs | +| [`stop`](#php-stop) | Stop all services | +| [`status`](#php-status) | Show service status | +| [`ssl`](#php-ssl) | Setup SSL certificates with mkcert | + +### Build & Production + +| Command | Description | +|---------|-------------| +| [`build`](#php-build) | Build Docker or LinuxKit image | +| [`serve`](#php-serve) | Run production container | +| [`shell`](#php-shell) | Open shell in running container | + +### Code Quality + +| Command | Description | +|---------|-------------| +| [`test`](#php-test) | Run PHP tests (PHPUnit/Pest) | +| [`fmt`](#php-fmt) | Format code with Laravel Pint | +| [`analyse`](#php-analyse) | Run PHPStan static analysis | + +### Package Management + +| Command | Description | +|---------|-------------| +| [`packages link`](#php-packages-link) | Link local packages by path | +| [`packages unlink`](#php-packages-unlink) | Unlink packages by name | +| [`packages update`](#php-packages-update) | Update linked packages | +| [`packages list`](#php-packages-list) | List linked packages | + +### Deployment (Coolify) + +| Command | Description | +|---------|-------------| +| [`deploy`](#php-deploy) | Deploy to Coolify | +| [`deploy:status`](#php-deploystatus) | Show deployment status | +| [`deploy:rollback`](#php-deployrollback) | Rollback to previous deployment | +| [`deploy:list`](#php-deploylist) | List recent deployments | + +--- + +## php dev + +Start the Laravel development environment with all detected services. + +```bash +core php dev [flags] +``` + +### Services Orchestrated + +- **FrankenPHP/Octane** - HTTP server (port 8000, HTTPS on 443) +- **Vite** - Frontend dev server (port 5173) +- **Laravel Horizon** - Queue workers +- **Laravel Reverb** - WebSocket server (port 8080) +- **Redis** - Cache and queue backend (port 6379) + +### Flags + +| Flag | Description | +|------|-------------| +| `--no-vite` | Skip Vite dev server | +| `--no-horizon` | Skip Laravel Horizon | +| `--no-reverb` | Skip Laravel Reverb | +| `--no-redis` | Skip Redis server | +| `--https` | Enable HTTPS with mkcert | +| `--domain` | Domain for SSL certificate (default: from APP_URL) | +| `--port` | FrankenPHP port (default: 8000) | + +### Examples + +```bash +# Start all detected services +core php dev + +# With HTTPS +core php dev --https + +# Skip optional services +core php dev --no-horizon --no-reverb +``` + +--- + +## php logs + +Stream unified logs from all running services. + +```bash +core php logs [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--follow` | Follow log output | +| `--service` | Specific service (frankenphp, vite, horizon, reverb, redis) | + +--- + +## php stop + +Stop all running Laravel services. + +```bash +core php stop +``` + +--- + +## php status + +Show the status of all Laravel services and project configuration. + +```bash +core php status +``` + +--- + +## php ssl + +Setup local SSL certificates using mkcert. + +```bash +core php ssl [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--domain` | Domain for certificate (default: from APP_URL or localhost) | + +--- + +## php build + +Build a production-ready container image. + +```bash +core php build [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--type` | Build type: `docker` (default) or `linuxkit` | +| `--name` | Image name (default: project directory name) | +| `--tag` | Image tag (default: latest) | +| `--platform` | Target platform (e.g., linux/amd64, linux/arm64) | +| `--dockerfile` | Path to custom Dockerfile | +| `--output` | Output path for LinuxKit image | +| `--format` | LinuxKit format: qcow2 (default), iso, raw, vmdk | +| `--template` | LinuxKit template name (default: server-php) | +| `--no-cache` | Build without cache | + +### Examples + +```bash +# Build Docker image +core php build + +# With custom name and tag +core php build --name myapp --tag v1.0 + +# Build LinuxKit image +core php build --type linuxkit +``` + +--- + +## php serve + +Run a production container. + +```bash +core php serve [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--name` | Docker image name (required) | +| `--tag` | Image tag (default: latest) | +| `--container` | Container name | +| `--port` | HTTP port (default: 80) | +| `--https-port` | HTTPS port (default: 443) | +| `-d` | Run in detached mode | +| `--env-file` | Path to environment file | + +### Examples + +```bash +core php serve --name myapp +core php serve --name myapp -d +core php serve --name myapp --port 8080 +``` + +--- + +## php shell + +Open an interactive shell in a running container. + +```bash +core php shell +``` + +--- + +## php test + +Run PHP tests using PHPUnit or Pest. + +```bash +core php test [flags] +``` + +Auto-detects Pest if `tests/Pest.php` exists. + +### Flags + +| Flag | Description | +|------|-------------| +| `--parallel` | Run tests in parallel | +| `--coverage` | Generate code coverage | +| `--filter` | Filter tests by name pattern | +| `--group` | Run only tests in specified group | + +### Examples + +```bash +core php test +core php test --parallel --coverage +core php test --filter UserTest +``` + +--- + +## php fmt + +Format PHP code using Laravel Pint. + +```bash +core php fmt [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--fix` | Auto-fix formatting issues | +| `--diff` | Show diff of changes | + +--- + +## php analyse + +Run PHPStan or Larastan static analysis. + +```bash +core php analyse [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--level` | PHPStan analysis level (0-9) | +| `--memory` | Memory limit (e.g., 2G) | + +--- + +## php packages link + +Link local PHP packages for development. + +```bash +core php packages link [...] +``` + +Adds path repositories to composer.json with symlink enabled. + +--- + +## php packages unlink + +Remove linked packages from composer.json. + +```bash +core php packages unlink [...] +``` + +--- + +## php packages update + +Update linked packages via Composer. + +```bash +core php packages update [...] +``` + +--- + +## php packages list + +List all locally linked packages. + +```bash +core php packages list +``` + +--- + +## php deploy + +Deploy the PHP application to Coolify. + +```bash +core php deploy [flags] +``` + +### Configuration + +Requires environment variables in `.env`: +``` +COOLIFY_URL=https://coolify.example.com +COOLIFY_TOKEN=your-api-token +COOLIFY_APP_ID=production-app-id +COOLIFY_STAGING_APP_ID=staging-app-id +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--staging` | Deploy to staging environment | +| `--force` | Force deployment even if no changes detected | +| `--wait` | Wait for deployment to complete | + +--- + +## php deploy:status + +Show the status of a deployment. + +```bash +core php deploy:status [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--staging` | Check staging environment | +| `--id` | Specific deployment ID | + +--- + +## php deploy:rollback + +Rollback to a previous deployment. + +```bash +core php deploy:rollback [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--staging` | Rollback staging environment | +| `--id` | Specific deployment ID to rollback to | +| `--wait` | Wait for rollback to complete | + +--- + +## php deploy:list + +List recent deployments. + +```bash +core php deploy:list [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--staging` | List staging deployments | +| `--limit` | Number of deployments (default: 10) | + +--- + +## Configuration + +Optional `.core/php.yaml` - see [Configuration](example.md#configuration) for examples. diff --git a/docs/build/cli/pkg/example.md b/docs/build/cli/pkg/example.md new file mode 100644 index 0000000..7904aae --- /dev/null +++ b/docs/build/cli/pkg/example.md @@ -0,0 +1,36 @@ +# Package Examples + +## Search + +```bash +core pkg search core- +core pkg search api +core pkg search --org myorg +``` + +## Install + +```bash +core pkg install core-api +core pkg install host-uk/core-api +``` + +## List + +```bash +core pkg list +core pkg list --format json +``` + +## Update + +```bash +core pkg update +core pkg update core-api +``` + +## Outdated + +```bash +core pkg outdated +``` diff --git a/docs/build/cli/pkg/index.md b/docs/build/cli/pkg/index.md new file mode 100644 index 0000000..fcc218b --- /dev/null +++ b/docs/build/cli/pkg/index.md @@ -0,0 +1,144 @@ +# core pkg + +Package management for host-uk repositories. + +## Usage + +```bash +core pkg [flags] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [`search`](#pkg-search) | Search GitHub for packages | +| [`install`](#pkg-install) | Clone a package from GitHub | +| [`list`](#pkg-list) | List installed packages | +| [`update`](#pkg-update) | Update installed packages | +| [`outdated`](#pkg-outdated) | Check for outdated packages | + +--- + +## pkg search + +Search GitHub for host-uk packages. + +```bash +core pkg search [flags] +``` + +Results are cached for 1 hour in `.core/cache/`. + +### Flags + +| Flag | Description | +|------|-------------| +| `--org` | GitHub organisation (default: host-uk) | +| `--pattern` | Repo name pattern (* for wildcard) | +| `--type` | Filter by type in name (mod, services, plug, website) | +| `--limit` | Max results (default: 50) | +| `--refresh` | Bypass cache and fetch fresh data | + +### Examples + +```bash +# List all repos in org +core pkg search + +# Search for core-* repos +core pkg search --pattern 'core-*' + +# Search different org +core pkg search --org mycompany + +# Bypass cache +core pkg search --refresh +``` + +--- + +## pkg install + +Clone a package from GitHub. + +```bash +core pkg install [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--dir` | Target directory (default: ./packages or current dir) | +| `--add` | Add to repos.yaml registry | + +### Examples + +```bash +# Clone to packages/ +core pkg install host-uk/core-php + +# Clone to custom directory +core pkg install host-uk/core-tenant --dir ./packages + +# Clone and add to registry +core pkg install host-uk/core-admin --add +``` + +--- + +## pkg list + +List installed packages from repos.yaml. + +```bash +core pkg list +``` + +Shows installed status (✓) and description for each package. + +--- + +## pkg update + +Pull latest changes for installed packages. + +```bash +core pkg update [...] [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--all` | Update all packages | + +### Examples + +```bash +# Update specific package +core pkg update core-php + +# Update all packages +core pkg update --all +``` + +--- + +## pkg outdated + +Check which packages have unpulled commits. + +```bash +core pkg outdated +``` + +Fetches from remote and shows packages that are behind. + +--- + +## See Also + +- [setup](../setup/) - Clone all repos from registry +- [dev work](../dev/work/) - Multi-repo workflow diff --git a/docs/build/cli/pkg/search/example.md b/docs/build/cli/pkg/search/example.md new file mode 100644 index 0000000..fbcaa6f --- /dev/null +++ b/docs/build/cli/pkg/search/example.md @@ -0,0 +1,23 @@ +# Package Search Examples + +```bash +# Find all core-* packages +core pkg search core- + +# Search term +core pkg search api + +# Different org +core pkg search --org myorg query +``` + +## Output + +``` +┌──────────────┬─────────────────────────────┐ +│ Package │ Description │ +├──────────────┼─────────────────────────────┤ +│ core-api │ REST API framework │ +│ core-auth │ Authentication utilities │ +└──────────────┴─────────────────────────────┘ +``` diff --git a/docs/build/cli/pkg/search/index.md b/docs/build/cli/pkg/search/index.md new file mode 100644 index 0000000..57fea91 --- /dev/null +++ b/docs/build/cli/pkg/search/index.md @@ -0,0 +1,75 @@ +# core pkg search + +Search GitHub for repositories matching a pattern. + +Uses `gh` CLI for authenticated search. Results are cached for 1 hour. + +## Usage + +```bash +core pkg search [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--pattern` | Repo name pattern (* for wildcard) | +| `--org` | GitHub organization (default: host-uk) | +| `--type` | Filter by type in name (mod, services, plug, website) | +| `--limit` | Max results (default: 50) | +| `--refresh` | Bypass cache and fetch fresh data | + +## Examples + +```bash +# List all host-uk repos +core pkg search + +# Search for core-* repos +core pkg search --pattern "core-*" + +# Search different org +core pkg search --org mycompany + +# Filter by type +core pkg search --type services + +# Bypass cache +core pkg search --refresh + +# Combine filters +core pkg search --pattern "core-*" --type mod --limit 20 +``` + +## Output + +``` +Found 5 repositories: + + host-uk/core + Go CLI for the host-uk ecosystem + ★ 42 Go Updated 2 hours ago + + host-uk/core-php + PHP/Laravel packages for Core + ★ 18 PHP Updated 1 day ago + + host-uk/core-images + Docker and LinuxKit images + ★ 8 Dockerfile Updated 3 days ago +``` + +## Authentication + +Uses GitHub CLI (`gh`) authentication. Ensure you're logged in: + +```bash +gh auth status +gh auth login # if not authenticated +``` + +## See Also + +- [pkg install](../) - Clone a package from GitHub +- [setup command](../../setup/) - Clone all repos from registry diff --git a/docs/build/cli/sdk/example.md b/docs/build/cli/sdk/example.md new file mode 100644 index 0000000..2fada8c --- /dev/null +++ b/docs/build/cli/sdk/example.md @@ -0,0 +1,35 @@ +# SDK Examples + +## Validate + +```bash +core sdk validate +core sdk validate --spec ./api.yaml +``` + +## Diff + +```bash +# Compare with tag +core sdk diff --base v1.0.0 + +# Compare files +core sdk diff --base ./old-api.yaml --spec ./new-api.yaml +``` + +## Output + +``` +Breaking changes detected: + +- DELETE /users/{id}/profile + Endpoint removed + +- PATCH /users/{id} + Required field 'email' added + +Non-breaking changes: + ++ POST /users/{id}/avatar + New endpoint added +``` diff --git a/docs/build/cli/sdk/index.md b/docs/build/cli/sdk/index.md new file mode 100644 index 0000000..bd6828c --- /dev/null +++ b/docs/build/cli/sdk/index.md @@ -0,0 +1,106 @@ +# core sdk + +SDK validation and API compatibility tools. + +To generate SDKs, use: `core build sdk` + +## Usage + +```bash +core sdk [flags] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `diff` | Check for breaking API changes | +| `validate` | Validate OpenAPI spec | + +## sdk validate + +Validate an OpenAPI specification file. + +```bash +core sdk validate [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--spec` | Path to OpenAPI spec file (auto-detected) | + +### Examples + +```bash +# Validate detected spec +core sdk validate + +# Validate specific file +core sdk validate --spec api/openapi.yaml +``` + +## sdk diff + +Check for breaking changes between API versions. + +```bash +core sdk diff [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `--base` | Base spec version (git tag or file path) | +| `--spec` | Current spec file (auto-detected) | + +### Examples + +```bash +# Compare against previous release +core sdk diff --base v1.0.0 + +# Compare two files +core sdk diff --base old-api.yaml --spec new-api.yaml +``` + +### Breaking Changes Detected + +- Removed endpoints +- Changed parameter types +- Removed required fields +- Changed response types + +## SDK Generation + +SDK generation is handled by `core build sdk`, not this command. + +```bash +# Generate SDKs +core build sdk + +# Generate specific language +core build sdk --lang typescript + +# Preview without writing +core build sdk --dry-run +``` + +See [build sdk](../build/sdk/) for generation details. + +## Spec Auto-Detection + +Core looks for OpenAPI specs in this order: + +1. Path specified in config (`sdk.spec`) +2. `openapi.yaml` / `openapi.json` +3. `api/openapi.yaml` / `api/openapi.json` +4. `docs/openapi.yaml` / `docs/openapi.json` +5. Laravel Scramble endpoint (`/docs/api.json`) + +## See Also + +- [build sdk](../build/sdk/) - Generate SDKs from OpenAPI +- [ci command](../ci/) - Release workflow diff --git a/docs/build/cli/setup/example.md b/docs/build/cli/setup/example.md new file mode 100644 index 0000000..23f2410 --- /dev/null +++ b/docs/build/cli/setup/example.md @@ -0,0 +1,293 @@ +# Setup Examples + +## Clone from Registry + +```bash +# Clone all repos defined in repos.yaml +core setup + +# Preview what would be cloned +core setup --dry-run + +# Only foundation packages +core setup --only foundation + +# Multiple types +core setup --only foundation,module + +# Use specific registry file +core setup --registry ~/projects/repos.yaml +``` + +## Bootstrap New Workspace + +```bash +# In an empty directory - bootstraps in place +mkdir my-workspace && cd my-workspace +core setup + +# Shows interactive wizard to select packages: +# ┌─────────────────────────────────────────────┐ +# │ Select packages to clone │ +# │ Use space to select, enter to confirm │ +# │ │ +# │ ── Foundation (core framework) ── │ +# │ ☑ core-php Foundation framework │ +# │ ☑ core-tenant Multi-tenancy module │ +# │ │ +# │ ── Products (applications) ── │ +# │ ☐ core-bio Link-in-bio product │ +# │ ☐ core-social Social scheduling │ +# └─────────────────────────────────────────────┘ + +# Non-interactive: clone all packages +core setup --all + +# Create workspace in subdirectory +cd ~/Code +core setup --name my-project + +# CI mode: fully non-interactive +core setup --all --name ci-test +``` + +## Setup Single Repository + +```bash +# In a git repo without .core/ configuration +cd ~/Code/my-go-project +core setup + +# Shows choice dialog: +# ┌─────────────────────────────────────────────┐ +# │ Setup options │ +# │ You're in a git repository. What would you │ +# │ like to do? │ +# │ │ +# │ ● Setup this repo (create .core/ config) │ +# │ ○ Create a new workspace (clone repos) │ +# └─────────────────────────────────────────────┘ + +# Preview generated configuration +core setup --dry-run + +# Output: +# → Setting up repository configuration +# +# ✓ Detected project type: go +# → Also found: (none) +# +# → Would create: +# /Users/you/Code/my-go-project/.core/build.yaml +# +# Configuration preview: +# version: 1 +# project: +# name: my-go-project +# description: Go application +# main: ./cmd/my-go-project +# binary: my-go-project +# ... +``` + +## Configuration Files + +### repos.yaml (Workspace Registry) + +```yaml +org: host-uk +base_path: . +defaults: + ci: github + license: EUPL-1.2 + branch: main +repos: + core-php: + type: foundation + description: Foundation framework + core-tenant: + type: module + depends_on: [core-php] + description: Multi-tenancy module + core-admin: + type: module + depends_on: [core-php, core-tenant] + description: Admin panel + core-bio: + type: product + depends_on: [core-php, core-tenant] + description: Link-in-bio product + domain: bio.host.uk.com + core-devops: + type: foundation + clone: false # Already exists, skip cloning +``` + +### .core/build.yaml (Repository Config) + +Generated for Go projects: + +```yaml +version: 1 +project: + name: my-project + description: Go application + main: ./cmd/my-project + binary: my-project +build: + cgo: false + flags: + - -trimpath + ldflags: + - -s + - -w + env: [] +targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 +sign: + enabled: false +``` + +Generated for Wails projects: + +```yaml +version: 1 +project: + name: my-app + description: Wails desktop application + main: . + binary: my-app +targets: + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 + - os: linux + arch: amd64 +``` + +### .core/release.yaml (Release Config) + +Generated for Go projects: + +```yaml +version: 1 +project: + name: my-project + repository: owner/my-project + +changelog: + include: + - feat + - fix + - perf + - refactor + exclude: + - chore + - docs + - style + - test + +publishers: + - type: github + draft: false + prerelease: false +``` + +### .core/test.yaml (Test Config) + +Generated for Go projects: + +```yaml +version: 1 + +commands: + - name: unit + run: go test ./... + - name: coverage + run: go test -coverprofile=coverage.out ./... + - name: race + run: go test -race ./... + +env: + CGO_ENABLED: "0" +``` + +Generated for PHP projects: + +```yaml +version: 1 + +commands: + - name: unit + run: vendor/bin/pest --parallel + - name: types + run: vendor/bin/phpstan analyse + - name: lint + run: vendor/bin/pint --test + +env: + APP_ENV: testing + DB_CONNECTION: sqlite +``` + +Generated for Node.js projects: + +```yaml +version: 1 + +commands: + - name: unit + run: npm test + - name: lint + run: npm run lint + - name: typecheck + run: npm run typecheck + +env: + NODE_ENV: test +``` + +## Workflow Examples + +### New Developer Setup + +```bash +# Clone the workspace +mkdir host-uk && cd host-uk +core setup + +# Select packages in wizard, then: +core health # Check all repos are healthy +core doctor # Verify environment +``` + +### CI Pipeline Setup + +```bash +# Non-interactive full clone +core setup --all --name workspace + +# Or with specific packages +core setup --only foundation,module --name workspace +``` + +### Adding Build Config to Existing Repo + +```bash +cd my-existing-project +core setup # Choose "Setup this repo" +# Edit .core/build.yaml as needed +core build # Build the project +``` diff --git a/docs/build/cli/setup/index.md b/docs/build/cli/setup/index.md new file mode 100644 index 0000000..d07121f --- /dev/null +++ b/docs/build/cli/setup/index.md @@ -0,0 +1,213 @@ +# core setup + +Clone repositories from registry or bootstrap a new workspace. + +## Overview + +The `setup` command operates in three modes: + +1. **Registry mode** - When `repos.yaml` exists nearby, clones repositories into packages/ +2. **Bootstrap mode** - When no registry exists, clones `core-devops` first, then presents an interactive wizard to select packages +3. **Repo setup mode** - When run in a git repo root, offers to create `.core/build.yaml` configuration + +## Usage + +```bash +core setup [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--registry` | Path to repos.yaml (auto-detected if not specified) | +| `--dry-run` | Show what would be cloned without cloning | +| `--only` | Only clone repos of these types (comma-separated: foundation,module,product) | +| `--all` | Skip wizard, clone all packages (non-interactive) | +| `--name` | Project directory name for bootstrap mode | +| `--build` | Run build after cloning | + +--- + +## Registry Mode + +When `repos.yaml` is found nearby (current directory or parents), setup clones all defined repositories: + +```bash +# In a directory with repos.yaml +core setup + +# Preview what would be cloned +core setup --dry-run + +# Only clone foundation packages +core setup --only foundation + +# Multiple types +core setup --only foundation,module +``` + +In registry mode with a TTY, an interactive wizard allows you to select which packages to clone. Use `--all` to skip the wizard and clone everything. + +--- + +## Bootstrap Mode + +When no `repos.yaml` exists, setup enters bootstrap mode: + +```bash +# In an empty directory - bootstraps workspace in place +mkdir my-project && cd my-project +core setup + +# In a non-empty directory - creates subdirectory +cd ~/Code +core setup --name my-workspace + +# Non-interactive: clone all packages +core setup --all --name ci-test +``` + +Bootstrap mode: +1. Detects if current directory is empty +2. If not empty, prompts for project name (or uses `--name`) +3. Clones `core-devops` (contains `repos.yaml`) +4. Loads the registry from core-devops +5. Shows interactive package selection wizard (unless `--all`) +6. Clones selected packages +7. Optionally runs build (with `--build`) + +--- + +## Repo Setup Mode + +When run in a git repository root (without `repos.yaml`), setup offers two choices: + +1. **Setup Working Directory** - Creates `.core/build.yaml` based on detected project type +2. **Create Package** - Creates a subdirectory and clones packages there + +```bash +cd ~/Code/my-go-project +core setup + +# Output: +# >> This directory is a git repository +# > Setup Working Directory +# Create Package (clone repos into subdirectory) +``` + +Choosing "Setup Working Directory" detects the project type and generates configuration: + +| Detected File | Project Type | +|---------------|--------------| +| `wails.json` | Wails | +| `go.mod` | Go | +| `composer.json` | PHP | +| `package.json` | Node.js | + +Creates three config files in `.core/`: + +| File | Purpose | +|------|---------| +| `build.yaml` | Build targets, flags, output settings | +| `release.yaml` | Changelog format, GitHub release config | +| `test.yaml` | Test commands, environment variables | + +Also auto-detects GitHub repo from git remote for release config. + +See [Configuration Files](example.md#configuration-files) for generated config examples. + +--- + +## Interactive Wizard + +When running in a terminal (TTY), the setup command presents an interactive multi-select wizard: + +- Packages are grouped by type (foundation, module, product, template) +- Use arrow keys to navigate +- Press space to select/deselect packages +- Type to filter the list +- Press enter to confirm selection + +The wizard is skipped when: +- `--all` flag is specified +- Not running in a TTY (e.g., CI pipelines) +- `--dry-run` is specified + +--- + +## Examples + +### Clone from Registry + +```bash +# Clone all repos (interactive wizard) +core setup + +# Clone all repos (non-interactive) +core setup --all + +# Preview without cloning +core setup --dry-run + +# Only foundation packages +core setup --only foundation +``` + +### Bootstrap New Workspace + +```bash +# Interactive bootstrap in empty directory +mkdir workspace && cd workspace +core setup + +# Non-interactive with all packages +core setup --all --name my-project + +# Bootstrap and run build +core setup --all --name my-project --build +``` + +--- + +## Registry Format + +The registry file (`repos.yaml`) defines repositories. See [Configuration Files](example.md#configuration-files) for format. + +--- + +## Finding Registry + +Core looks for `repos.yaml` in: + +1. Current directory +2. Parent directories (walking up to root) +3. `~/Code/host-uk/repos.yaml` +4. `~/.config/core/repos.yaml` + +--- + +## After Setup + +```bash +# Check workspace health +core dev health + +# Full workflow (status + commit + push) +core dev work + +# Build the project +core build + +# Run tests +core go test # Go projects +core php test # PHP projects +``` + +--- + +## See Also + +- [dev work](../dev/work/) - Multi-repo operations +- [build](../build/) - Build projects +- [doctor](../doctor/) - Check environment diff --git a/docs/build/cli/test/example.md b/docs/build/cli/test/example.md new file mode 100644 index 0000000..9e2a4a7 --- /dev/null +++ b/docs/build/cli/test/example.md @@ -0,0 +1,8 @@ +# Test Examples + +**Note:** Prefer `core go test` or `core php test` instead. + +```bash +core test +core test --coverage +``` diff --git a/docs/build/cli/test/index.md b/docs/build/cli/test/index.md new file mode 100644 index 0000000..920baea --- /dev/null +++ b/docs/build/cli/test/index.md @@ -0,0 +1,74 @@ +# core test + +Run Go tests with coverage reporting. + +Sets `MACOSX_DEPLOYMENT_TARGET=26.0` to suppress linker warnings on macOS. + +## Usage + +```bash +core test [flags] +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--coverage` | Show detailed per-package coverage | +| `--json` | Output JSON for CI/agents | +| `--pkg` | Package pattern to test (default: ./...) | +| `--race` | Enable race detector | +| `--run` | Run only tests matching this regex | +| `--short` | Skip long-running tests | +| `--verbose` | Show test output as it runs | + +## Examples + +```bash +# Run all tests with coverage summary +core test + +# Show test output as it runs +core test --verbose + +# Detailed per-package coverage +core test --coverage + +# Test specific packages +core test --pkg ./pkg/... + +# Run specific test by name +core test --run TestName + +# Run tests matching pattern +core test --run "Test.*Good" + +# Skip long-running tests +core test --short + +# Enable race detector +core test --race + +# Output JSON for CI/agents +core test --json +``` + +## JSON Output + +With `--json`, outputs structured results: + +```json +{ + "passed": 14, + "failed": 0, + "skipped": 0, + "coverage": 75.1, + "exit_code": 0, + "failed_packages": [] +} +``` + +## See Also + +- [go test](../go/test/) - Go-specific test options +- [go cov](../go/cov/) - Coverage reports diff --git a/docs/build/cli/vm/example.md b/docs/build/cli/vm/example.md new file mode 100644 index 0000000..f31f97e --- /dev/null +++ b/docs/build/cli/vm/example.md @@ -0,0 +1,52 @@ +# VM Examples + +## Running VMs + +```bash +# Run image +core vm run server.iso + +# Detached with resources +core vm run -d --memory 4096 --cpus 4 server.iso + +# From template +core vm run --template core-dev --var SSH_KEY="ssh-rsa AAAA..." +``` + +## Management + +```bash +# List running +core vm ps + +# Include stopped +core vm ps -a + +# Stop +core vm stop abc123 + +# View logs +core vm logs abc123 + +# Follow logs +core vm logs -f abc123 + +# Execute command +core vm exec abc123 ls -la + +# Shell +core vm exec abc123 /bin/sh +``` + +## Templates + +```bash +# List +core vm templates + +# Show content +core vm templates show core-dev + +# Show variables +core vm templates vars core-dev +``` diff --git a/docs/build/cli/vm/index.md b/docs/build/cli/vm/index.md new file mode 100644 index 0000000..ec0be0f --- /dev/null +++ b/docs/build/cli/vm/index.md @@ -0,0 +1,163 @@ +# core vm + +LinuxKit VM management. + +LinuxKit VMs are lightweight, immutable VMs built from YAML templates. +They run using qemu or hyperkit depending on your system. + +## Usage + +```bash +core vm [flags] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [`run`](#vm-run) | Run a LinuxKit image or template | +| [`ps`](#vm-ps) | List running VMs | +| [`stop`](#vm-stop) | Stop a VM | +| [`logs`](#vm-logs) | View VM logs | +| [`exec`](#vm-exec) | Execute command in VM | +| [templates](templates/) | Manage LinuxKit templates | + +--- + +## vm run + +Run a LinuxKit image or build from a template. + +```bash +core vm run [flags] +core vm run --template [flags] +``` + +Supported image formats: `.iso`, `.qcow2`, `.vmdk`, `.raw` + +### Flags + +| Flag | Description | +|------|-------------| +| `--template` | Run from a LinuxKit template (build + run) | +| `--var` | Template variable in KEY=VALUE format (repeatable) | +| `--name` | Name for the container | +| `--memory` | Memory in MB (default: 1024) | +| `--cpus` | CPU count (default: 1) | +| `--ssh-port` | SSH port for exec commands (default: 2222) | +| `-d` | Run in detached mode (background) | + +### Examples + +```bash +# Run from image file +core vm run image.iso + +# Run detached with more resources +core vm run -d image.qcow2 --memory 2048 --cpus 4 + +# Run from template +core vm run --template core-dev --var SSH_KEY="ssh-rsa AAAA..." + +# Multiple template variables +core vm run --template server-php --var SSH_KEY="..." --var DOMAIN=example.com +``` + +--- + +## vm ps + +List running VMs. + +```bash +core vm ps [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-a` | Show all (including stopped) | + +### Output + +``` +ID NAME IMAGE STATUS STARTED PID +abc12345 myvm ...core-dev.qcow2 running 5m 12345 +``` + +--- + +## vm stop + +Stop a running VM by ID or name. + +```bash +core vm stop +``` + +Supports partial ID matching. + +### Examples + +```bash +# Full ID +core vm stop abc12345678 + +# Partial ID +core vm stop abc1 +``` + +--- + +## vm logs + +View VM logs. + +```bash +core vm logs [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-f` | Follow log output | + +### Examples + +```bash +# View logs +core vm logs abc12345 + +# Follow logs +core vm logs -f abc1 +``` + +--- + +## vm exec + +Execute a command in a running VM via SSH. + +```bash +core vm exec +``` + +### Examples + +```bash +# List files +core vm exec abc12345 ls -la + +# Open shell +core vm exec abc1 /bin/sh +``` + +--- + +## See Also + +- [templates](templates/) - Manage LinuxKit templates +- [build](../build/) - Build LinuxKit images +- [dev](../dev/) - Dev environment management diff --git a/docs/build/cli/vm/templates/example.md b/docs/build/cli/vm/templates/example.md new file mode 100644 index 0000000..c1f8b35 --- /dev/null +++ b/docs/build/cli/vm/templates/example.md @@ -0,0 +1,53 @@ +# VM Templates Examples + +## List + +```bash +core vm templates +``` + +## Show + +```bash +core vm templates show core-dev +``` + +## Variables + +```bash +core vm templates vars core-dev +``` + +## Output + +``` +Variables for core-dev: + SSH_KEY (required) SSH public key + MEMORY (optional) Memory in MB (default: 4096) + CPUS (optional) CPU count (default: 4) +``` + +## Using Templates + +```bash +core vm run --template core-dev --var SSH_KEY="ssh-rsa AAAA..." +``` + +## Template Format + +`.core/linuxkit/myserver.yml`: + +```yaml +kernel: + image: linuxkit/kernel:5.15 + cmdline: "console=tty0" + +init: + - linuxkit/init:v1.0.0 + +services: + - name: sshd + image: linuxkit/sshd:v1.0.0 + - name: myapp + image: ghcr.io/myorg/myapp:latest +``` diff --git a/docs/build/cli/vm/templates/index.md b/docs/build/cli/vm/templates/index.md new file mode 100644 index 0000000..7ca3700 --- /dev/null +++ b/docs/build/cli/vm/templates/index.md @@ -0,0 +1,124 @@ +# core vm templates + +Manage LinuxKit templates for container images. + +## Usage + +```bash +core vm templates [command] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `list` | List available templates | +| `show` | Show template details | +| `vars` | Show template variables | + +## templates list + +List all available LinuxKit templates. + +```bash +core vm templates list +``` + +### Output + +``` +Available Templates: + + core-dev + Full development environment with 100+ tools + Platforms: linux/amd64, linux/arm64 + + server-php + FrankenPHP production server + Platforms: linux/amd64, linux/arm64 + + edge-node + Minimal edge deployment + Platforms: linux/amd64, linux/arm64 +``` + +## templates show + +Show details of a specific template. + +```bash +core vm templates show +``` + +### Example + +```bash +core vm templates show core-dev +``` + +Output: +``` +Template: core-dev + +Description: Full development environment with 100+ tools + +Platforms: + - linux/amd64 + - linux/arm64 + +Formats: + - iso + - qcow2 + +Services: + - sshd + - docker + - frankenphp + +Size: ~1.8GB +``` + +## templates vars + +Show variables defined by a template. + +```bash +core vm templates vars +``` + +### Example + +```bash +core vm templates vars core-dev +``` + +Output: +``` +Variables for core-dev: + SSH_KEY (required) SSH public key + MEMORY (optional) Memory in MB (default: 4096) + CPUS (optional) CPU count (default: 4) +``` + +## Template Locations + +Templates are searched in order: + +1. `.core/linuxkit/` - Project-specific +2. `~/.core/templates/` - User templates +3. Built-in templates + +## Creating Templates + +Create a LinuxKit YAML in `.core/linuxkit/`. See [Template Format](example.md#template-format) for examples. + +Run with: + +```bash +core vm run --template myserver +``` + +## See Also + +- [vm command](../) - Run LinuxKit images +- [build command](../../build/) - Build LinuxKit images diff --git a/docs/build/go/configuration.md b/docs/build/go/configuration.md new file mode 100644 index 0000000..deabb68 --- /dev/null +++ b/docs/build/go/configuration.md @@ -0,0 +1,357 @@ +# Configuration + +Core uses `.core/` directory for project configuration. + +## Directory Structure + +``` +.core/ +├── release.yaml # Release configuration +├── build.yaml # Build configuration (optional) +├── php.yaml # PHP configuration (optional) +└── linuxkit/ # LinuxKit templates + ├── server.yml + └── dev.yml +``` + +## release.yaml + +Full release configuration reference: + +```yaml +version: 1 + +project: + name: myapp + repository: myorg/myapp + +build: + targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 + +publishers: + # GitHub Releases (required - others reference these artifacts) + - type: github + prerelease: false + draft: false + + # npm binary wrapper + - type: npm + package: "@myorg/myapp" + access: public # or "restricted" + + # Homebrew formula + - type: homebrew + tap: myorg/homebrew-tap + formula: myapp + official: + enabled: false + output: dist/homebrew + + # Scoop manifest (Windows) + - type: scoop + bucket: myorg/scoop-bucket + official: + enabled: false + output: dist/scoop + + # AUR (Arch Linux) + - type: aur + maintainer: "Name " + + # Chocolatey (Windows) + - type: chocolatey + push: false # true to publish + + # Docker multi-arch + - type: docker + registry: ghcr.io + image: myorg/myapp + dockerfile: Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + tags: + - latest + - "{{.Version}}" + build_args: + VERSION: "{{.Version}}" + + # LinuxKit images + - type: linuxkit + config: .core/linuxkit/server.yml + formats: + - iso + - qcow2 + - docker + platforms: + - linux/amd64 + - linux/arm64 + +changelog: + include: + - feat + - fix + - perf + - refactor + exclude: + - chore + - docs + - style + - test + - ci +``` + +## build.yaml + +Optional build configuration: + +```yaml +version: 1 + +project: + name: myapp + binary: myapp + +build: + main: ./cmd/myapp + env: + CGO_ENABLED: "0" + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + +targets: + - os: linux + arch: amd64 + - os: darwin + arch: arm64 +``` + +## php.yaml + +PHP/Laravel configuration: + +```yaml +version: 1 + +dev: + domain: myapp.test + ssl: true + port: 8000 + services: + - frankenphp + - vite + - horizon + - reverb + - redis + +test: + parallel: true + coverage: false + +deploy: + coolify: + server: https://coolify.example.com + project: my-project + environment: production +``` + +## LinuxKit Templates + +LinuxKit YAML configuration: + +```yaml +kernel: + image: linuxkit/kernel:6.6 + cmdline: "console=tty0 console=ttyS0" + +init: + - linuxkit/init:latest + - linuxkit/runc:latest + - linuxkit/containerd:latest + - linuxkit/ca-certificates:latest + +onboot: + - name: sysctl + image: linuxkit/sysctl:latest + +services: + - name: dhcpcd + image: linuxkit/dhcpcd:latest + - name: sshd + image: linuxkit/sshd:latest + - name: myapp + image: myorg/myapp:latest + capabilities: + - CAP_NET_BIND_SERVICE + +files: + - path: /etc/myapp/config.yaml + contents: | + server: + port: 8080 +``` + +## repos.yaml + +Package registry for multi-repo workspaces: + +```yaml +# Organisation name (used for GitHub URLs) +org: host-uk + +# Base path for cloning (default: current directory) +base_path: . + +# Default settings for all repos +defaults: + ci: github + license: EUPL-1.2 + branch: main + +# Repository definitions +repos: + # Foundation packages (no dependencies) + core-php: + type: foundation + description: Foundation framework + + core-devops: + type: foundation + description: Development environment + clone: false # Skip during setup (already exists) + + # Module packages (depend on foundation) + core-tenant: + type: module + depends_on: [core-php] + description: Multi-tenancy module + + core-admin: + type: module + depends_on: [core-php, core-tenant] + description: Admin panel + + core-api: + type: module + depends_on: [core-php] + description: REST API framework + + # Product packages (user-facing applications) + core-bio: + type: product + depends_on: [core-php, core-tenant] + description: Link-in-bio product + domain: bio.host.uk.com + + core-social: + type: product + depends_on: [core-php, core-tenant] + description: Social scheduling + domain: social.host.uk.com + + # Templates + core-template: + type: template + description: Starter template for new projects +``` + +### repos.yaml Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `org` | Yes | GitHub organisation name | +| `base_path` | No | Directory for cloning (default: `.`) | +| `defaults` | No | Default settings applied to all repos | +| `repos` | Yes | Map of repository definitions | + +### Repository Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `type` | Yes | `foundation`, `module`, `product`, or `template` | +| `description` | No | Human-readable description | +| `depends_on` | No | List of package dependencies | +| `clone` | No | Set `false` to skip during setup | +| `domain` | No | Production domain (for products) | +| `branch` | No | Override default branch | + +### Package Types + +| Type | Description | Dependencies | +|------|-------------|--------------| +| `foundation` | Core framework packages | None | +| `module` | Reusable modules | Foundation packages | +| `product` | User-facing applications | Foundation + modules | +| `template` | Starter templates | Any | + +--- + +## Environment Variables + +Complete reference of environment variables used by Core CLI. + +### Authentication + +| Variable | Used By | Description | +|----------|---------|-------------| +| `GITHUB_TOKEN` | `core ci`, `core dev` | GitHub API authentication | +| `ANTHROPIC_API_KEY` | `core ai`, `core dev claude` | Claude API key | +| `AGENTIC_TOKEN` | `core ai task*` | Agentic API authentication | +| `AGENTIC_BASE_URL` | `core ai task*` | Agentic API endpoint | + +### Publishing + +| Variable | Used By | Description | +|----------|---------|-------------| +| `NPM_TOKEN` | `core ci` (npm publisher) | npm registry auth token | +| `CHOCOLATEY_API_KEY` | `core ci` (chocolatey publisher) | Chocolatey API key | +| `DOCKER_USERNAME` | `core ci` (docker publisher) | Docker registry username | +| `DOCKER_PASSWORD` | `core ci` (docker publisher) | Docker registry password | + +### Deployment + +| Variable | Used By | Description | +|----------|---------|-------------| +| `COOLIFY_URL` | `core php deploy` | Coolify server URL | +| `COOLIFY_TOKEN` | `core php deploy` | Coolify API token | +| `COOLIFY_APP_ID` | `core php deploy` | Production application ID | +| `COOLIFY_STAGING_APP_ID` | `core php deploy --staging` | Staging application ID | + +### Build + +| Variable | Used By | Description | +|----------|---------|-------------| +| `CGO_ENABLED` | `core build`, `core go *` | Enable/disable CGO (default: 0) | +| `GOOS` | `core build` | Target operating system | +| `GOARCH` | `core build` | Target architecture | + +### Configuration Paths + +| Variable | Description | +|----------|-------------| +| `CORE_CONFIG` | Override config directory (default: `~/.core/`) | +| `CORE_REGISTRY` | Override repos.yaml path | + +--- + +## Defaults + +If no configuration exists, sensible defaults are used: + +- **Targets**: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 +- **Publishers**: GitHub only +- **Changelog**: feat, fix, perf, refactor included diff --git a/docs/build/go/getting-started.md b/docs/build/go/getting-started.md new file mode 100644 index 0000000..ad374ab --- /dev/null +++ b/docs/build/go/getting-started.md @@ -0,0 +1,191 @@ +# Getting Started + +This guide walks you through installing Core and running your first build. + +## Prerequisites + +Before installing Core, ensure you have: + +| Tool | Minimum Version | Check Command | +|------|-----------------|---------------| +| Go | 1.23+ | `go version` | +| Git | 2.30+ | `git --version` | + +Optional (for specific features): + +| Tool | Required For | Install | +|------|--------------|---------| +| `gh` | GitHub integration (`core dev issues`, `core dev reviews`) | [cli.github.com](https://cli.github.com) | +| Docker | Container builds | [docker.com](https://docker.com) | +| `task` | Task automation | `go install github.com/go-task/task/v3/cmd/task@latest` | + +## Installation + +### Option 1: Go Install (Recommended) + +```bash +# Install latest release +go install github.com/host-uk/core/cmd/core@latest + +# Verify installation +core doctor +``` + +If `core: command not found`, add Go's bin directory to your PATH: + +```bash +export PATH="$PATH:$(go env GOPATH)/bin" +``` + +### Option 2: Download Binary + +Download pre-built binaries from [GitHub Releases](https://github.com/host-uk/core/releases): + +```bash +# macOS (Apple Silicon) +curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-darwin-arm64 +chmod +x core +sudo mv core /usr/local/bin/ + +# macOS (Intel) +curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-darwin-amd64 +chmod +x core +sudo mv core /usr/local/bin/ + +# Linux (x86_64) +curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-linux-amd64 +chmod +x core +sudo mv core /usr/local/bin/ +``` + +### Option 3: Build from Source + +```bash +# Clone repository +git clone https://github.com/host-uk/core.git +cd core + +# Build with Task (recommended) +task cli:build +# Binary at ./bin/core + +# Or build with Go directly +CGO_ENABLED=0 go build -o core ./cmd/core/ +sudo mv core /usr/local/bin/ +``` + +## Your First Build + +### 1. Navigate to a Go Project + +```bash +cd ~/Code/my-go-project +``` + +### 2. Initialise Configuration + +```bash +core setup +``` + +This detects your project type and creates configuration files in `.core/`: +- `build.yaml` - Build settings +- `release.yaml` - Release configuration +- `test.yaml` - Test commands + +### 3. Build + +```bash +core build +``` + +Output appears in `dist/`: + +``` +dist/ +├── my-project-darwin-arm64.tar.gz +├── my-project-linux-amd64.tar.gz +└── CHECKSUMS.txt +``` + +### 4. Cross-Compile (Optional) + +```bash +core build --targets linux/amd64,linux/arm64,darwin/arm64,windows/amd64 +``` + +## Your First Release + +Releases are **safe by default** - Core runs in dry-run mode unless you explicitly confirm. + +### 1. Preview + +```bash +core ci +``` + +This shows what would be published without actually publishing. + +### 2. Publish + +```bash +core ci --we-are-go-for-launch +``` + +This creates a GitHub release with your built artifacts. + +## Multi-Repo Workflow + +If you work with multiple repositories (like the host-uk ecosystem): + +### 1. Clone All Repositories + +```bash +mkdir host-uk && cd host-uk +core setup +``` + +Select packages in the interactive wizard. + +### 2. Check Status + +```bash +core dev health +# Output: "18 repos │ clean │ synced" +``` + +### 3. Work Across Repos + +```bash +core dev work --status # See status table +core dev work # Commit and push all dirty repos +``` + +## Next Steps + +| Task | Command | Documentation | +|------|---------|---------------| +| Run tests | `core go test` | [go/test](cmd/go/test/) | +| Format code | `core go fmt --fix` | [go/fmt](cmd/go/fmt/) | +| Lint code | `core go lint` | [go/lint](cmd/go/lint/) | +| PHP development | `core php dev` | [php](cmd/php/) | +| View all commands | `core --help` | [cmd](cmd/) | + +## Getting Help + +```bash +# Check environment +core doctor + +# Command help +core --help + +# Full documentation +https://github.com/host-uk/core/tree/main/docs +``` + +## See Also + +- [Configuration](configuration.md) - All config options +- [Workflows](workflows.md) - Common task sequences +- [Troubleshooting](troubleshooting.md) - When things go wrong diff --git a/docs/build/go/glossary.md b/docs/build/go/glossary.md new file mode 100644 index 0000000..ea9d280 --- /dev/null +++ b/docs/build/go/glossary.md @@ -0,0 +1,112 @@ +# Glossary + +Definitions of terms used throughout Core CLI documentation. + +## A + +### Artifact +A file produced by a build, typically a binary, archive, or checksum file. Artifacts are stored in the `dist/` directory and published during releases. + +## C + +### CGO +Go's mechanism for calling C code. Core disables CGO by default (`CGO_ENABLED=0`) to produce statically-linked binaries that don't depend on system libraries. + +### Changelog +Automatically generated list of changes between releases, created from conventional commit messages. Configure in `.core/release.yaml`. + +### Conventional Commits +A commit message format: `type(scope): description`. Types include `feat`, `fix`, `docs`, `chore`. Core uses this to generate changelogs. + +## D + +### Dry-run +A mode where commands show what they would do without actually doing it. `core ci` runs in dry-run mode by default for safety. + +## F + +### Foundation Package +A core package with no dependencies on other packages. Examples: `core-php`, `core-devops`. These form the base of the dependency tree. + +### FrankenPHP +A modern PHP application server used by `core php dev`. Combines PHP with Caddy for high-performance serving. + +## G + +### `gh` +The GitHub CLI tool. Required for commands that interact with GitHub: `core dev issues`, `core dev reviews`, `core dev ci`. + +## L + +### LinuxKit +A toolkit for building lightweight, immutable Linux distributions. Core can build LinuxKit images via `core build --type linuxkit`. + +## M + +### Module (Go) +A collection of Go packages with a `go.mod` file. Core's Go commands operate on modules. + +### Module (Package) +A host-uk package that depends on foundation packages. Examples: `core-tenant`, `core-admin`. Compare with **Foundation Package** and **Product**. + +## P + +### Package +An individual repository in the host-uk ecosystem. Packages are defined in `repos.yaml` and managed with `core pkg` commands. + +### Package Index +The `repos.yaml` file that lists all packages in a workspace. Contains metadata like dependencies, type, and description. + +### Product +A user-facing application package. Examples: `core-bio`, `core-social`. Products depend on foundation and module packages. + +### Publisher +A release target configured in `.core/release.yaml`. Types include `github`, `docker`, `npm`, `homebrew`, `linuxkit`. + +## R + +### Registry (Docker/npm) +A remote repository for container images or npm packages. Core can publish to registries during releases. + +### `repos.yaml` +The package index file defining all repositories in a workspace. Used by multi-repo commands like `core dev work`. + +## S + +### SDK +Software Development Kit. Core can generate API client SDKs from OpenAPI specs via `core build sdk`. + +## T + +### Target +A build target specified as `os/arch`, e.g., `linux/amd64`, `darwin/arm64`. Use `--targets` flag to specify. + +## W + +### Wails +A framework for building desktop applications with Go backends and web frontends. Core detects Wails projects and uses appropriate build commands. + +### Workspace (Go) +A Go 1.18+ feature for working with multiple modules simultaneously. Managed via `core go work` commands. + +### Workspace (Multi-repo) +A directory containing multiple packages from `repos.yaml`. Created via `core setup` and managed with `core dev` commands. + +## Symbols + +### `.core/` +Directory containing project configuration files: +- `build.yaml` - Build settings +- `release.yaml` - Release targets +- `test.yaml` - Test configuration +- `linuxkit/` - LinuxKit templates + +### `--we-are-go-for-launch` +Flag to disable dry-run mode and actually publish a release. Named as a deliberate friction to prevent accidental releases. + +--- + +## See Also + +- [Configuration](configuration.md) - Config file reference +- [Getting Started](getting-started.md) - First-time setup diff --git a/docs/build/go/index.md b/docs/build/go/index.md new file mode 100644 index 0000000..155a047 --- /dev/null +++ b/docs/build/go/index.md @@ -0,0 +1,98 @@ +# Core Go + +Core is a Go framework for the host-uk ecosystem - build, release, and deploy Go, Wails, PHP, and container workloads. + +## Installation + +```bash +# Via Go (recommended) +go install github.com/host-uk/core/cmd/core@latest + +# Or download binary from releases +curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-$(go env GOOS)-$(go env GOARCH) +chmod +x core && sudo mv core /usr/local/bin/ + +# Verify +core doctor +``` + +See [Getting Started](getting-started.md) for all installation options including building from source. + +## Command Reference + +See [CLI](/build/cli/) for full command documentation. + +| Command | Description | +|---------|-------------| +| [go](/build/cli/go/) | Go development (test, fmt, lint, cov) | +| [php](/build/cli/php/) | Laravel/PHP development | +| [build](/build/cli/build/) | Build Go, Wails, Docker, LinuxKit projects | +| [ci](/build/cli/ci/) | Publish releases (dry-run by default) | +| [sdk](/build/cli/sdk/) | SDK generation and validation | +| [dev](/build/cli/dev/) | Multi-repo workflow + dev environment | +| [pkg](/build/cli/pkg/) | Package search and install | +| [vm](/build/cli/vm/) | LinuxKit VM management | +| [docs](/build/cli/docs/) | Documentation management | +| [setup](/build/cli/setup/) | Clone repos from registry | +| [doctor](/build/cli/doctor/) | Check development environment | + +## Quick Start + +```bash +# Go development +core go test # Run tests +core go test --coverage # With coverage +core go fmt # Format code +core go lint # Lint code + +# Build +core build # Auto-detect and build +core build --targets linux/amd64,darwin/arm64 + +# Release (dry-run by default) +core ci # Preview release +core ci --we-are-go-for-launch # Actually publish + +# Multi-repo workflow +core dev work # Status + commit + push +core dev work --status # Just show status + +# PHP development +core php dev # Start dev environment +core php test # Run tests +``` + +## Configuration + +Core uses `.core/` directory for project configuration: + +``` +.core/ +├── release.yaml # Release targets and settings +├── build.yaml # Build configuration (optional) +└── linuxkit/ # LinuxKit templates +``` + +And `repos.yaml` in workspace root for multi-repo management. + +## Guides + +- [Getting Started](getting-started.md) - Installation and first steps +- [Workflows](workflows.md) - Common task sequences +- [Troubleshooting](troubleshooting.md) - When things go wrong +- [Migration](migration.md) - Moving from legacy tools + +## Reference + +- [Configuration](configuration.md) - All config options +- [Glossary](glossary.md) - Term definitions + +## Claude Code Skill + +Install the skill to teach Claude Code how to use the Core CLI: + +```bash +curl -fsSL https://raw.githubusercontent.com/host-uk/core/main/.claude/skills/core/install.sh | bash +``` + +See [skill/](skill/) for details. diff --git a/docs/build/go/migration.md b/docs/build/go/migration.md new file mode 100644 index 0000000..e5c4606 --- /dev/null +++ b/docs/build/go/migration.md @@ -0,0 +1,233 @@ +# Migration Guide + +Migrating from legacy scripts and tools to Core CLI. + +## From push-all.sh + +The `push-all.sh` script has been replaced by `core dev` commands. + +| Legacy | Core CLI | Notes | +|--------|----------|-------| +| `./push-all.sh --status` | `core dev work --status` | Status table | +| `./push-all.sh --commit` | `core dev commit` | Commit dirty repos | +| `./push-all.sh` | `core dev work` | Full workflow | + +### Quick Migration + +```bash +# Instead of +./push-all.sh --status + +# Use +core dev work --status +``` + +### New Features + +Core CLI adds features not available in the legacy script: + +```bash +# Quick health summary +core dev health +# Output: "18 repos │ clean │ synced" + +# Pull repos that are behind +core dev pull + +# GitHub integration +core dev issues # List open issues +core dev reviews # List PRs needing review +core dev ci # Check CI status + +# Dependency analysis +core dev impact core-php # What depends on core-php? +``` + +--- + +## From Raw Go Commands + +Core wraps Go commands with enhanced defaults and output. + +| Raw Command | Core CLI | Benefits | +|-------------|----------|----------| +| `go test ./...` | `core go test` | Filters warnings, sets CGO_ENABLED=0 | +| `go test -coverprofile=...` | `core go cov` | HTML reports, thresholds | +| `gofmt -w .` | `core go fmt --fix` | Uses goimports if available | +| `golangci-lint run` | `core go lint` | Consistent interface | +| `go build` | `core build` | Cross-compile, sign, archive | + +### Why Use Core? + +```bash +# Raw go test shows linker warnings on macOS +go test ./... +# ld: warning: -no_pie is deprecated... + +# Core filters noise +core go test +# PASS (clean output) +``` + +### Environment Setup + +Core automatically sets: +- `CGO_ENABLED=0` - Static binaries +- `MACOSX_DEPLOYMENT_TARGET=26.0` - Suppress macOS warnings +- Colour output for coverage reports + +--- + +## From Raw PHP Commands + +Core orchestrates Laravel development services. + +| Raw Command | Core CLI | Benefits | +|-------------|----------|----------| +| `php artisan serve` | `core php dev` | Adds Vite, Horizon, Reverb, Redis | +| `./vendor/bin/pest` | `core php test` | Auto-detects test runner | +| `./vendor/bin/pint` | `core php fmt --fix` | Consistent interface | +| Manual Coolify deploy | `core php deploy` | Tracked, scriptable | + +### Development Server Comparison + +```bash +# Raw: Start each service manually +php artisan serve & +npm run dev & +php artisan horizon & +php artisan reverb:start & + +# Core: One command +core php dev +# Starts all services, shows unified logs +``` + +--- + +## From goreleaser + +Core's release system is simpler than goreleaser for host-uk projects. + +| goreleaser | Core CLI | +|------------|----------| +| `.goreleaser.yaml` | `.core/release.yaml` | +| `goreleaser release --snapshot` | `core ci` (dry-run) | +| `goreleaser release` | `core ci --we-are-go-for-launch` | + +### Configuration Migration + +**goreleaser:** +```yaml +builds: + - main: ./cmd/app + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + +archives: + - format: tar.gz + files: [LICENSE, README.md] + +release: + github: + owner: host-uk + name: app +``` + +**Core:** +```yaml +version: 1 + +project: + name: app + repository: host-uk/app + +targets: + - os: linux + arch: amd64 + - os: darwin + arch: arm64 + +publishers: + - type: github +``` + +### Key Differences + +1. **Separate build and release** - Core separates `core build` from `core ci` +2. **Safe by default** - `core ci` is dry-run unless `--we-are-go-for-launch` +3. **Simpler config** - Fewer options, sensible defaults + +--- + +## From Manual Git Operations + +Core automates multi-repo git workflows. + +| Manual | Core CLI | +|--------|----------| +| `cd repo1 && git status && cd ../repo2 && ...` | `core dev work --status` | +| Check each repo for uncommitted changes | `core dev health` | +| Commit each repo individually | `core dev commit` | +| Push each repo individually | `core dev push` | + +### Example: Committing Across Repos + +**Manual:** +```bash +cd core-php +git add -A +git commit -m "feat: add feature" +cd ../core-tenant +git add -A +git commit -m "feat: use new feature" +# ... repeat for each repo +``` + +**Core:** +```bash +core dev commit +# Interactive: reviews changes, suggests messages +# Adds Co-Authored-By automatically +``` + +--- + +## Deprecated Commands + +These commands have been removed or renamed: + +| Deprecated | Replacement | Version | +|------------|-------------|---------| +| `core sdk generate` | `core build sdk` | v0.5.0 | +| `core dev task*` | `core ai task*` | v0.8.0 | +| `core release` | `core ci` | v0.6.0 | + +--- + +## Version Compatibility + +| Core Version | Go Version | Breaking Changes | +|--------------|------------|------------------| +| v1.0.0+ | 1.23+ | Stable API | +| v0.8.0 | 1.22+ | Task commands moved to `ai` | +| v0.6.0 | 1.22+ | Release command renamed to `ci` | +| v0.5.0 | 1.21+ | SDK generation moved to `build sdk` | + +--- + +## Getting Help + +If you encounter issues during migration: + +1. Check [Troubleshooting](troubleshooting.md) +2. Run `core doctor` to verify setup +3. Use `--help` on any command: `core dev work --help` + +--- + +## See Also + +- [Getting Started](getting-started.md) - Fresh installation +- [Workflows](workflows.md) - Common task sequences +- [Configuration](configuration.md) - Config file reference diff --git a/docs/build/go/skill/index.md b/docs/build/go/skill/index.md new file mode 100644 index 0000000..40ae3ad --- /dev/null +++ b/docs/build/go/skill/index.md @@ -0,0 +1,35 @@ +# Claude Code Skill + +The `core` skill teaches Claude Code how to use the Core CLI effectively. + +## Installation + +```bash +curl -fsSL https://raw.githubusercontent.com/host-uk/core/main/.claude/skills/core/install.sh | bash +``` + +Or if you have the repo cloned: + +```bash +./.claude/skills/core/install.sh +``` + +## What it does + +Once installed, Claude Code will: + +- Auto-invoke when working in host-uk repositories +- Use `core` commands instead of raw `go`/`php`/`git` commands +- Follow the correct patterns for testing, building, and releasing + +## Manual invocation + +Type `/core` in Claude Code to invoke the skill manually. + +## Updating + +Re-run the install command to update to the latest version. + +## Location + +Skills are installed to `~/.claude/skills/core/SKILL.md`. diff --git a/docs/build/go/troubleshooting.md b/docs/build/go/troubleshooting.md new file mode 100644 index 0000000..c075f3a --- /dev/null +++ b/docs/build/go/troubleshooting.md @@ -0,0 +1,332 @@ +# Troubleshooting + +Common issues and how to resolve them. + +## Installation Issues + +### "command not found: core" + +**Cause:** Go's bin directory is not in your PATH. + +**Fix:** + +```bash +# Add to ~/.bashrc or ~/.zshrc +export PATH="$PATH:$(go env GOPATH)/bin" + +# Then reload +source ~/.bashrc # or ~/.zshrc +``` + +### "go: module github.com/host-uk/core: no matching versions" + +**Cause:** Go module proxy hasn't cached the latest version yet. + +**Fix:** + +```bash +# Bypass proxy +GOPROXY=direct go install github.com/host-uk/core/cmd/core@latest +``` + +--- + +## Build Issues + +### "no Go files in..." + +**Cause:** Core couldn't find a main package to build. + +**Fix:** + +1. Check you're in the correct directory +2. Ensure `.core/build.yaml` has the correct `main` path: + +```yaml +project: + main: ./cmd/myapp # Path to main package +``` + +### "CGO_ENABLED=1 but no C compiler" + +**Cause:** Build requires CGO but no C compiler is available. + +**Fix:** + +```bash +# Option 1: Disable CGO (if not needed) +core build # Core disables CGO by default + +# Option 2: Install a C compiler +# macOS +xcode-select --install + +# Ubuntu/Debian +sudo apt install build-essential + +# Windows +# Install MinGW or use WSL +``` + +### Build succeeds but binary doesn't run + +**Cause:** Built for wrong architecture. + +**Fix:** + +```bash +# Check what you built +file dist/myapp-* + +# Build for your current platform +core build --targets $(go env GOOS)/$(go env GOARCH) +``` + +--- + +## Release Issues + +### "dry-run mode, use --we-are-go-for-launch to publish" + +**This is expected behaviour.** Core runs in dry-run mode by default for safety. + +**To actually publish:** + +```bash +core ci --we-are-go-for-launch +``` + +### "failed to create release: 401 Unauthorized" + +**Cause:** GitHub token missing or invalid. + +**Fix:** + +```bash +# Authenticate with GitHub CLI +gh auth login + +# Or set token directly +export GITHUB_TOKEN=ghp_xxxxxxxxxxxx +``` + +### "no artifacts found in dist/" + +**Cause:** You need to build before releasing. + +**Fix:** + +```bash +# Build first +core build + +# Then release +core ci --we-are-go-for-launch +``` + +### "tag already exists" + +**Cause:** Trying to release a version that's already been released. + +**Fix:** + +1. Update version in your code/config +2. Or delete the existing tag (if intentional): + +```bash +git tag -d v1.0.0 +git push origin :refs/tags/v1.0.0 +``` + +--- + +## Multi-Repo Issues + +### "repos.yaml not found" + +**Cause:** Core can't find the package registry. + +**Fix:** + +Core looks for `repos.yaml` in: +1. Current directory +2. Parent directories (walking up to root) +3. `~/Code/host-uk/repos.yaml` +4. `~/.config/core/repos.yaml` + +Either: +- Run commands from a directory with `repos.yaml` +- Use `--registry /path/to/repos.yaml` +- Run `core setup` to bootstrap a new workspace + +### "failed to clone: Permission denied (publickey)" + +**Cause:** SSH key not configured for GitHub. + +**Fix:** + +```bash +# Check SSH connection +ssh -T git@github.com + +# If that fails, add your key +ssh-add ~/.ssh/id_ed25519 + +# Or configure SSH +# See: https://docs.github.com/en/authentication/connecting-to-github-with-ssh +``` + +### "repository not found" during setup + +**Cause:** You don't have access to the repository, or it doesn't exist. + +**Fix:** + +1. Check you're authenticated: `gh auth status` +2. Verify the repo exists and you have access +3. For private repos, ensure your token has `repo` scope + +--- + +## GitHub Integration Issues + +### "gh: command not found" + +**Cause:** GitHub CLI not installed. + +**Fix:** + +```bash +# macOS +brew install gh + +# Ubuntu/Debian +sudo apt install gh + +# Windows +winget install GitHub.cli + +# Then authenticate +gh auth login +``` + +### "core dev issues" shows nothing + +**Possible causes:** + +1. No open issues exist +2. Not authenticated with GitHub +3. Not in a directory with `repos.yaml` + +**Fix:** + +```bash +# Check auth +gh auth status + +# Check you're in a workspace +ls repos.yaml + +# Show all issues including closed +core dev issues --all +``` + +--- + +## PHP Issues + +### "frankenphp: command not found" + +**Cause:** FrankenPHP not installed. + +**Fix:** + +```bash +# macOS +brew install frankenphp + +# Or use Docker +core php dev --docker +``` + +### "core php dev" exits immediately + +**Cause:** Usually a port conflict or missing dependency. + +**Fix:** + +```bash +# Check if port 8000 is in use +lsof -i :8000 + +# Try a different port +core php dev --port 9000 + +# Check logs for errors +core php logs +``` + +--- + +## Performance Issues + +### Commands are slow + +**Possible causes:** + +1. Large number of repositories +2. Network latency to GitHub +3. Go module downloads + +**Fix:** + +```bash +# For multi-repo commands, use health for quick check +core dev health # Fast summary + +# Instead of +core dev work --status # Full table (slower) + +# Pre-download Go modules +go mod download +``` + +--- + +## Getting More Help + +### Enable Verbose Output + +Most commands support `-v` or `--verbose`: + +```bash +core build -v +core go test -v +``` + +### Check Environment + +```bash +core doctor +``` + +This verifies all required tools are installed and configured. + +### Report Issues + +If you've found a bug: + +1. Check existing issues: https://github.com/host-uk/core/issues +2. Create a new issue with: + - Core version (`core --version`) + - OS and architecture (`go env GOOS GOARCH`) + - Command that failed + - Full error output + +--- + +## See Also + +- [Getting Started](getting-started.md) - Installation and first steps +- [Configuration](configuration.md) - Config file reference +- [doctor](cmd/doctor/) - Environment verification diff --git a/docs/build/go/workflows.md b/docs/build/go/workflows.md new file mode 100644 index 0000000..96b0c9f --- /dev/null +++ b/docs/build/go/workflows.md @@ -0,0 +1,334 @@ +# Workflows + +Common end-to-end workflows for Core CLI. + +## Go Project: Build and Release + +Complete workflow from code to GitHub release. + +```bash +# 1. Run tests +core go test + +# 2. Check coverage +core go cov --threshold 80 + +# 3. Format and lint +core go fmt --fix +core go lint + +# 4. Build for all platforms +core build --targets linux/amd64,linux/arm64,darwin/arm64,windows/amd64 + +# 5. Preview release (dry-run) +core ci + +# 6. Publish +core ci --we-are-go-for-launch +``` + +**Output structure:** + +``` +dist/ +├── myapp-darwin-arm64.tar.gz +├── myapp-linux-amd64.tar.gz +├── myapp-linux-arm64.tar.gz +├── myapp-windows-amd64.zip +└── CHECKSUMS.txt +``` + +--- + +## PHP Project: Development to Deployment + +Local development through to production deployment. + +```bash +# 1. Start development environment +core php dev + +# 2. Run tests (in another terminal) +core php test --parallel + +# 3. Check code quality +core php fmt --fix +core php analyse + +# 4. Deploy to staging +core php deploy --staging --wait + +# 5. Verify staging +# (manual testing) + +# 6. Deploy to production +core php deploy --wait + +# 7. Monitor +core php deploy:status +``` + +**Rollback if needed:** + +```bash +core php deploy:rollback +``` + +--- + +## Multi-Repo: Daily Workflow + +Working across the host-uk monorepo. + +### Morning: Sync Everything + +```bash +# Quick health check +core dev health + +# Pull all repos that are behind +core dev pull --all + +# Check for issues assigned to you +core dev issues --assignee @me +``` + +### During Development + +```bash +# Work on code... + +# Check status across all repos +core dev work --status + +# Commit changes (Claude-assisted messages) +core dev commit + +# Push when ready +core dev push +``` + +### End of Day + +```bash +# Full workflow: status → commit → push +core dev work + +# Check CI status +core dev ci + +# Review any failed builds +core dev ci --failed +``` + +--- + +## New Developer: Environment Setup + +First-time setup for a new team member. + +```bash +# 1. Verify prerequisites +core doctor + +# 2. Create workspace directory +mkdir ~/Code/host-uk && cd ~/Code/host-uk + +# 3. Bootstrap workspace (interactive) +core setup + +# 4. Select packages in wizard +# Use arrow keys, space to select, enter to confirm + +# 5. Verify setup +core dev health + +# 6. Start working +core dev work --status +``` + +--- + +## CI Pipeline: Automated Build + +Example GitHub Actions workflow. + +```yaml +# .github/workflows/release.yml +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Install Core + run: go install github.com/host-uk/core/cmd/core@latest + + - name: Build + run: core build --ci + + - name: Release + run: core ci --we-are-go-for-launch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +--- + +## SDK Generation: API Client Updates + +Generate SDK clients when API changes. + +```bash +# 1. Validate OpenAPI spec +core sdk validate + +# 2. Check for breaking changes +core sdk diff --base v1.0.0 + +# 3. Generate SDKs +core build sdk + +# 4. Review generated code +git diff + +# 5. Commit if satisfied +git add -A && git commit -m "chore: regenerate SDK clients" +``` + +--- + +## Dependency Update: Cross-Repo Change + +When updating a shared package like `core-php`. + +```bash +# 1. Make changes in core-php +cd ~/Code/host-uk/core-php +# ... edit code ... + +# 2. Run tests +core go test # or core php test + +# 3. Check what depends on core-php +core dev impact core-php + +# Output: +# core-tenant (direct) +# core-admin (via core-tenant) +# core-api (direct) +# ... + +# 4. Commit core-php changes +core dev commit + +# 5. Update dependent packages +cd ~/Code/host-uk +for pkg in core-tenant core-admin core-api; do + cd $pkg + composer update host-uk/core-php + core php test + cd .. +done + +# 6. Commit all updates +core dev work +``` + +--- + +## Hotfix: Emergency Production Fix + +Fast path for critical fixes. + +```bash +# 1. Create hotfix branch +git checkout -b hotfix/critical-bug main + +# 2. Make fix +# ... edit code ... + +# 3. Test +core go test --run TestCriticalPath + +# 4. Build +core build + +# 5. Preview release +core ci --prerelease + +# 6. Publish hotfix +core ci --we-are-go-for-launch --prerelease + +# 7. Merge back to main +git checkout main +git merge hotfix/critical-bug +git push +``` + +--- + +## Documentation: Sync Across Repos + +Keep documentation synchronised. + +```bash +# 1. List all docs +core docs list + +# 2. Sync to central location +core docs sync --output ./docs-site + +# 3. Review changes +git diff docs-site/ + +# 4. Commit +git add docs-site/ +git commit -m "docs: sync from packages" +``` + +--- + +## Troubleshooting: Failed Build + +When a build fails. + +```bash +# 1. Check environment +core doctor + +# 2. Clean previous artifacts +rm -rf dist/ + +# 3. Verbose build +core build -v + +# 4. If Go-specific issues +core go mod tidy +core go mod verify + +# 5. Check for test failures +core go test -v + +# 6. Review configuration +cat .core/build.yaml +``` + +--- + +## See Also + +- [Getting Started](getting-started.md) - First-time setup +- [Troubleshooting](troubleshooting.md) - When things go wrong +- [Configuration](configuration.md) - Config file reference diff --git a/docs/build/php/actions.md b/docs/build/php/actions.md new file mode 100644 index 0000000..e7e1a9b --- /dev/null +++ b/docs/build/php/actions.md @@ -0,0 +1,181 @@ +# Actions Pattern + +Actions are single-purpose, reusable classes that encapsulate business logic. They provide a clean, testable alternative to fat controllers and model methods. + +## Basic Action + +```php + 'My Post', 'content' => '...']); +``` + +## With Validation + +```php +use Illuminate\Support\Facades\Validator; + +class CreatePost +{ + use Action; + + public function handle(array $data): Post + { + $validated = Validator::make($data, [ + 'title' => 'required|max:255', + 'content' => 'required', + 'status' => 'required|in:draft,published', + ])->validate(); + + return Post::create($validated); + } +} +``` + +## With Authorization + +```php +class DeletePost +{ + use Action; + + public function handle(Post $post, User $user): bool + { + if (!$user->can('delete', $post)) { + throw new UnauthorizedException('Cannot delete this post'); + } + + $post->delete(); + + return true; + } +} + +// Usage +DeletePost::run($post, auth()->user()); +``` + +## With Events + +```php +class PublishPost +{ + use Action; + + public function handle(Post $post): Post + { + $post->update([ + 'status' => 'published', + 'published_at' => now(), + ]); + + event(new PostPublished($post)); + + return $post; + } +} +``` + +## As Job + +```php +class CreatePost +{ + use Action; + + public function asJob(): bool + { + return true; // Run as queued job + } + + public function handle(array $data): Post + { + // Heavy processing... + return Post::create($data); + } +} + +// Automatically queued +CreatePost::run($data); +``` + +## Best Practices + +### 1. Single Responsibility +```php +// ✅ Good - one action, one purpose +CreatePost::run($data); +UpdatePost::run($post, $data); +DeletePost::run($post); + +// ❌ Bad - multiple responsibilities +ManagePost::run($action, $post, $data); +``` + +### 2. Type Hints +```php +// ✅ Good - clear types +public function handle(Post $post, User $user): bool + +// ❌ Bad - no types +public function handle($post, $user) +``` + +### 3. Descriptive Names +```php +// ✅ Good +PublishScheduledPosts +SendWeeklyNewsletter +GenerateMonthlyReport + +// ❌ Bad +ProcessPosts +DoWork +HandleIt +``` + +## Testing + +```php +use Tests\TestCase; +use Mod\Blog\Actions\CreatePost; + +class CreatePostTest extends TestCase +{ + public function test_creates_post(): void + { + $post = CreatePost::run([ + 'title' => 'Test Post', + 'content' => 'Content', + ]); + + $this->assertDatabaseHas('posts', [ + 'title' => 'Test Post', + ]); + } +} +``` + +## Learn More +- [Lifecycle Events →](/core/events) +- [Module System →](/core/modules) diff --git a/docs/build/php/activity.md b/docs/build/php/activity.md new file mode 100644 index 0000000..4fd6632 --- /dev/null +++ b/docs/build/php/activity.md @@ -0,0 +1,531 @@ +# Activity Logging + +Track user actions, model changes, and system events with GDPR-compliant activity logging. + +## Basic Usage + +### Enabling Activity Logging + +Add the `LogsActivity` trait to your model: + +```php +log( + subject: $post, + event: 'published', + description: 'Post published to homepage', + causer: auth()->user() +); + +// Log with properties +$logger->log( + subject: $post, + event: 'viewed', + properties: [ + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ] +); +``` + +## Activity Model + +### Retrieving Activity + +```php +use Core\Activity\Models\Activity; + +// Get all activity +$activities = Activity::latest()->get(); + +// Get activity for specific model +$postActivity = Activity::forSubject($post)->get(); + +// Get activity by user +$userActivity = Activity::causedBy($user)->get(); + +// Get activity by event +$published = Activity::where('event', 'published')->get(); +``` + +### Activity Attributes + +```php +$activity = Activity::latest()->first(); + +$activity->subject; // The model that was acted upon +$activity->causer; // The user who caused the activity +$activity->event; // Event name (created, updated, deleted, etc.) +$activity->description; // Human-readable description +$activity->properties; // Additional data (array) +$activity->created_at; // When it occurred +``` + +### Relationships + +```php +// Subject (polymorphic) +$post = $activity->subject; + +// Causer (polymorphic) +$user = $activity->causer; + +// Workspace (if applicable) +$workspace = $activity->workspace; +``` + +## Activity Scopes + +### Filtering Activity + +```php +use Core\Activity\Models\Activity; + +// By date range +$activities = Activity::query() + ->whereBetween('created_at', [now()->subDays(7), now()]) + ->get(); + +// By event type +$activities = Activity::query() + ->whereIn('event', ['created', 'updated']) + ->get(); + +// By workspace +$activities = Activity::query() + ->where('workspace_id', $workspace->id) + ->get(); + +// Complex filters +$activities = Activity::query() + ->forSubject($post) + ->causedBy($user) + ->where('event', 'updated') + ->latest() + ->paginate(20); +``` + +### Custom Scopes + +```php +use Core\Activity\Scopes\ActivityScopes; + +// Add to Activity model +class Activity extends Model +{ + use ActivityScopes; + + public function scopeForWorkspace($query, $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + public function scopeWithinDays($query, $days) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } +} + +// Usage +$recent = Activity::withinDays(7) + ->forWorkspace($workspace->id) + ->get(); +``` + +## Customizing Logged Data + +### Controlling What's Logged + +```php +class Post extends Model +{ + use LogsActivity; + + // Only log these events + protected static $recordEvents = ['created', 'published']; + + // Exclude these attributes from change tracking + protected static $ignoreChangedAttributes = ['views', 'updated_at']; + + // Log only these attributes + protected static $logAttributes = ['title', 'status']; +} +``` + +### Custom Descriptions + +```php +class Post extends Model +{ + use LogsActivity; + + public function getActivityDescription(string $event): string + { + return match($event) { + 'created' => "Created post: {$this->title}", + 'updated' => "Updated post: {$this->title}", + 'published' => "Published post: {$this->title}", + default => "Post {$event}", + }; + } +} +``` + +### Custom Properties + +```php +class Post extends Model +{ + use LogsActivity; + + public function getActivityProperties(string $event): array + { + return [ + 'title' => $this->title, + 'category' => $this->category->name, + 'word_count' => str_word_count($this->content), + 'published_at' => $this->published_at?->toIso8601String(), + ]; + } +} +``` + +## GDPR Compliance + +### IP Address Hashing + +IP addresses are automatically hashed for privacy: + +```php +use Core\Crypt\LthnHash; + +// Automatically applied +$activity = Activity::create([ + 'properties' => [ + 'ip_address' => request()->ip(), // Hashed before storage + ], +]); + +// Verify IP match without storing plaintext +if (LthnHash::check(request()->ip(), $activity->properties['ip_address'])) { + // IP matches +} +``` + +### Data Retention + +```php +use Core\Activity\Console\ActivityPruneCommand; + +// Prune old activity (default: 90 days) +php artisan activity:prune + +// Custom retention +php artisan activity:prune --days=30 + +// Dry run +php artisan activity:prune --dry-run +``` + +**Scheduled Pruning:** + +```php +// app/Console/Kernel.php +protected function schedule(Schedule $schedule) +{ + $schedule->command('activity:prune') + ->daily() + ->at('02:00'); +} +``` + +### Right to Erasure + +```php +// Delete all activity for a user +Activity::causedBy($user)->delete(); + +// Delete activity for specific subject +Activity::forSubject($post)->delete(); + +// Anonymize instead of delete +Activity::causedBy($user)->update([ + 'causer_id' => null, + 'causer_type' => null, +]); +``` + +## Activity Feed + +### Building Activity Feeds + +```php +use Core\Activity\Models\Activity; + +// User's personal feed +$feed = Activity::causedBy($user) + ->with(['subject', 'causer']) + ->latest() + ->paginate(20); + +// Workspace activity feed +$feed = Activity::query() + ->where('workspace_id', $workspace->id) + ->whereIn('event', ['created', 'updated', 'published']) + ->with(['subject', 'causer']) + ->latest() + ->paginate(20); +``` + +### Rendering Activity + +```blade +{{-- resources/views/activity/feed.blade.php --}} +@foreach($activities as $activity) +
+
+ @if($activity->event === 'created') + ... + @elseif($activity->event === 'updated') + ... + @endif +
+ +
+

+ {{ $activity->causer?->name ?? 'System' }} + {{ $activity->description }} +

+ +
+
+@endforeach +``` + +### Livewire Component + +```php +when($this->workspaceId, fn($q) => $q->where('workspace_id', $this->workspaceId)) + ->whereIn('event', $this->events) + ->where('created_at', '>=', now()->subDays($this->days)) + ->with(['subject', 'causer']) + ->latest() + ->paginate(20); + + return view('activity::admin.activity-feed', [ + 'activities' => $activities, + ]); + } +} +``` + +## Performance Optimization + +### Eager Loading + +```php +// ✅ Good - eager load relationships +$activities = Activity::query() + ->with(['subject', 'causer', 'workspace']) + ->latest() + ->get(); + +// ❌ Bad - N+1 queries +$activities = Activity::latest()->get(); +foreach ($activities as $activity) { + echo $activity->causer->name; // Query per iteration +} +``` + +### Chunking Large Datasets + +```php +// Process activity in chunks +Activity::query() + ->where('created_at', '<', now()->subDays(90)) + ->chunk(1000, function ($activities) { + foreach ($activities as $activity) { + $activity->delete(); + } + }); +``` + +### Queuing Activity Logging + +```php +// For high-traffic applications +use Illuminate\Bus\Queueable; + +class Post extends Model +{ + use LogsActivity; + + protected static $logActivityQueue = true; + + protected static $logActivityConnection = 'redis'; +} +``` + +## Analytics + +### Activity Statistics + +```php +use Core\Activity\Services\ActivityLogService; + +$analytics = app(ActivityLogService::class); + +// Count by event type +$stats = Activity::query() + ->where('workspace_id', $workspace->id) + ->whereBetween('created_at', [now()->subDays(30), now()]) + ->groupBy('event') + ->selectRaw('event, COUNT(*) as count') + ->get(); + +// Most active users +$topUsers = Activity::query() + ->selectRaw('causer_id, causer_type, COUNT(*) as activity_count') + ->groupBy('causer_id', 'causer_type') + ->orderByDesc('activity_count') + ->limit(10) + ->get(); +``` + +### Audit Reports + +```php +// Generate audit trail +$audit = Activity::query() + ->forSubject($post) + ->with('causer') + ->oldest() + ->get() + ->map(fn($activity) => [ + 'timestamp' => $activity->created_at->toIso8601String(), + 'user' => $activity->causer?->name ?? 'System', + 'event' => $activity->event, + 'changes' => $activity->properties, + ]); +``` + +## Best Practices + +### 1. Log Meaningful Events + +```php +// ✅ Good - business-relevant events +$logger->log($post, 'published', 'Post went live'); +$logger->log($order, 'payment_received', 'Customer paid'); + +// ❌ Bad - too granular +$logger->log($post, 'view_count_incremented', 'Views++'); +``` + +### 2. Include Context + +```php +// ✅ Good - rich context +$logger->log($post, 'published', properties: [ + 'category' => $post->category->name, + 'scheduled' => $post->published_at->isPast(), + 'author' => $post->author->name, +]); + +// ❌ Bad - no context +$logger->log($post, 'published'); +``` + +### 3. Respect Privacy + +```php +// ✅ Good - hash sensitive data +$logger->log($user, 'login', properties: [ + 'ip_address' => LthnHash::make(request()->ip()), +]); + +// ❌ Bad - plaintext IP +$logger->log($user, 'login', properties: [ + 'ip_address' => request()->ip(), +]); +``` + +## Testing + +```php +use Tests\TestCase; +use Core\Activity\Models\Activity; + +class ActivityTest extends TestCase +{ + public function test_logs_model_creation(): void + { + $post = Post::create(['title' => 'Test']); + + $this->assertDatabaseHas('activities', [ + 'subject_type' => Post::class, + 'subject_id' => $post->id, + 'event' => 'created', + ]); + } + + public function test_logs_changes(): void + { + $post = Post::factory()->create(['status' => 'draft']); + + $post->update(['status' => 'published']); + + $activity = Activity::latest()->first(); + $this->assertEquals('published', $activity->properties['status']); + } +} +``` + +## Learn More + +- [Multi-Tenancy →](/core/tenancy) +- [GDPR Compliance →](/security/overview) diff --git a/docs/build/php/architecture/custom-events.md b/docs/build/php/architecture/custom-events.md new file mode 100644 index 0000000..0af9145 --- /dev/null +++ b/docs/build/php/architecture/custom-events.md @@ -0,0 +1,546 @@ +# Creating Custom Events + +Learn how to create custom lifecycle events for extensibility in your modules. + +## Why Custom Events? + +Custom lifecycle events allow you to: +- Create extension points in your modules +- Enable third-party integrations +- Decouple module components +- Follow the framework's event-driven pattern + +## Basic Custom Event + +### Step 1: Create Event Class + +```php +gateways[$name] = $class; + } + + public function getGateways(): array + { + return $this->gateways; + } + + public function version(): string + { + return '1.0.0'; + } +} +``` + +### Step 2: Fire Event + +```php + 'onFrameworkBooted', + ]; + + public function onFrameworkBooted(FrameworkBooted $event): void + { + // Fire custom event + $gatewayEvent = new PaymentGatewaysRegistering(); + event($gatewayEvent); + + // Register all collected gateways + foreach ($gatewayEvent->getGateways() as $name => $class) { + app('payment.gateways')->register($name, $class); + } + } +} +``` + +### Step 3: Listen to Event + +```php + 'onPaymentGateways', + ]; + + public function onPaymentGateways(PaymentGatewaysRegistering $event): void + { + $event->gateway('stripe', StripeGateway::class); + } +} +``` + +## Event with Multiple Methods + +Provide different registration methods: + +```php +types[$name] = $model; + } + + public function renderer(string $type, string $class): void + { + $this->renderers[$type] = $class; + } + + public function validator(string $type, array $rules): void + { + $this->validators[$type] = $rules; + } + + public function getTypes(): array + { + return $this->types; + } + + public function getRenderers(): array + { + return $this->renderers; + } + + public function getValidators(): array + { + return $this->validators; + } +} +``` + +**Usage:** + +```php +public function onContentTypes(ContentTypesRegistering $event): void +{ + $event->type('video', Video::class); + $event->renderer('video', VideoRenderer::class); + $event->validator('video', [ + 'url' => 'required|url', + 'duration' => 'required|integer', + ]); +} +``` + +## Event with Configuration + +Pass configuration to listeners: + +```php +providers[$name] = [ + 'class' => $class, + 'config' => array_merge($this->config[$name] ?? [], $config), + ]; + } + + public function getProviders(): array + { + return $this->providers; + } +} +``` + +**Fire with Config:** + +```php +$event = new AnalyticsProvidersRegistering( + config('analytics.providers') +); +event($event); +``` + +## Event Versioning + +Track event versions for backward compatibility: + +```php +endpoints[] = compact('path', 'controller', 'options'); + } + + // v1 compatibility method (deprecated) + public function route(string $path, string $controller): void + { + $this->endpoint($path, $controller, ['deprecated' => true]); + } +} +``` + +**Check Version in Listener:** + +```php +public function onApiEndpoints(ApiEndpointsRegistering $event): void +{ + if (version_compare($event->version(), '2.0.0', '>=')) { + // Use v2 API + $event->endpoint('/posts', PostController::class, [ + 'middleware' => ['auth:sanctum'], + ]); + } else { + // Use v1 API (deprecated) + $event->route('/posts', PostController::class); + } +} +``` + +## Event Priority + +Control listener execution order: + +```php +themes[] = compact('name', 'class', 'priority'); + } + + public function getThemes(): array + { + // Sort by priority (higher first) + usort($this->themes, fn($a, $b) => $b['priority'] <=> $a['priority']); + + return $this->themes; + } +} +``` + +**Usage:** + +```php +public function onThemes(ThemesRegistering $event): void +{ + $event->theme('default', DefaultTheme::class, priority: 0); + $event->theme('premium', PremiumTheme::class, priority: 100); +} +``` + +## Event Validation + +Validate registrations: + +```php +fields[$type] = $class; + } + + public function getFields(): array + { + return $this->fields; + } +} +``` + +## Event Documentation + +Document your events with docblocks: + +```php +processor('watermark', WatermarkProcessor::class); + * $event->processor('thumbnail', ThumbnailProcessor::class); + * } + * ``` + */ +class MediaProcessorsRegistering extends LifecycleEvent +{ + protected array $processors = []; + + /** + * Register a media processor. + * + * @param string $name Processor name (e.g., 'watermark') + * @param string $class Processor class (must implement ProcessorInterface) + */ + public function processor(string $name, string $class): void + { + $this->processors[$name] = $class; + } + + /** + * Get all registered processors. + * + * @return array + */ + public function getProcessors(): array + { + return $this->processors; + } +} +``` + +## Testing Custom Events + +```php +app->boot(); + + Event::assertDispatched(PaymentGatewaysRegistering::class); + } + + public function test_registers_payment_gateway(): void + { + $event = new PaymentGatewaysRegistering(); + + $event->gateway('stripe', StripeGateway::class); + + $this->assertEquals( + ['stripe' => StripeGateway::class], + $event->getGateways() + ); + } + + public function test_stripe_module_registers_gateway(): void + { + $event = new PaymentGatewaysRegistering(); + + $boot = new \Mod\Stripe\Boot(); + $boot->onPaymentGateways($event); + + $this->assertArrayHasKey('stripe', $event->getGateways()); + } +} +``` + +## Best Practices + +### 1. Use Descriptive Names + +```php +// ✅ Good +class PaymentGatewaysRegistering extends LifecycleEvent + +// ❌ Bad +class RegisterGateways extends LifecycleEvent +``` + +### 2. Provide Fluent API + +```php +// ✅ Good - chainable +public function gateway(string $name, string $class): self +{ + $this->gateways[$name] = $class; + return $this; +} + +// Usage: +$event->gateway('stripe', StripeGateway::class) + ->gateway('paypal', PayPalGateway::class); +``` + +### 3. Validate Early + +```php +// ✅ Good - validate on registration +public function gateway(string $name, string $class): void +{ + if (!class_exists($class)) { + throw new InvalidArgumentException("Gateway class not found: {$class}"); + } + + $this->gateways[$name] = $class; +} +``` + +### 4. Version Your Events + +```php +// ✅ Good - versioned +use HasEventVersion; + +public function version(): string +{ + return '1.0.0'; +} +``` + +## Real-World Example + +Complete example of a custom event system: + +```php +// Event +class SearchProvidersRegistering extends LifecycleEvent +{ + use HasEventVersion; + + protected array $providers = []; + + public function provider( + string $name, + string $class, + int $priority = 0, + array $config = [] + ): void { + $this->providers[$name] = compact('class', 'priority', 'config'); + } + + public function getProviders(): array + { + uasort($this->providers, fn($a, $b) => $b['priority'] <=> $a['priority']); + return $this->providers; + } + + public function version(): string + { + return '1.0.0'; + } +} + +// Fire event +$event = new SearchProvidersRegistering(); +event($event); + +foreach ($event->getProviders() as $name => $config) { + app('search')->register($name, new $config['class']($config['config'])); +} + +// Listen to event +class Boot +{ + public static array $listens = [ + SearchProvidersRegistering::class => 'onSearchProviders', + ]; + + public function onSearchProviders(SearchProvidersRegistering $event): void + { + $event->provider('posts', PostSearchProvider::class, priority: 100); + $event->provider('users', UserSearchProvider::class, priority: 50); + } +} +``` + +## Learn More + +- [Lifecycle Events →](/packages/core/events) +- [Module System →](/packages/core/modules) diff --git a/docs/build/php/architecture/lazy-loading.md b/docs/build/php/architecture/lazy-loading.md new file mode 100644 index 0000000..284d8bd --- /dev/null +++ b/docs/build/php/architecture/lazy-loading.md @@ -0,0 +1,535 @@ +# Lazy Loading + +Core PHP Framework uses lazy loading to defer module instantiation until absolutely necessary. This dramatically improves performance by only loading code relevant to the current request. + +## How It Works + +### Traditional Approach (Everything Loads) + +```php +// Boot ALL modules on every request +$modules = [ + new BlogModule(), + new CommerceModule(), + new AnalyticsModule(), + new AdminModule(), + new ApiModule(), + // ... dozens more +]; + +// Web request loads admin code it doesn't need +// API request loads web views it doesn't use +// Memory: ~50MB, Boot time: ~500ms +``` + +### Lazy Loading Approach (On-Demand) + +```php +// Register listeners WITHOUT instantiating modules +Event::listen(WebRoutesRegistering::class, LazyModuleListener::for(BlogModule::class)); +Event::listen(AdminPanelBooting::class, LazyModuleListener::for(AdminModule::class)); + +// Web request → Only BlogModule instantiated +// API request → Only ApiModule instantiated +// Memory: ~15MB, Boot time: ~150ms +``` + +## Architecture + +### 1. Module Discovery + +`ModuleScanner` finds modules and extracts their event interests: + +```php +$modules = [ + [ + 'class' => Mod\Blog\Boot::class, + 'listens' => [ + WebRoutesRegistering::class => 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ], + ], + // ... +]; +``` + +### 2. Lazy Listener Registration + +`ModuleRegistry` creates lazy listeners for each event-module pair: + +```php +foreach ($modules as $module) { + foreach ($module['listens'] as $event => $method) { + Event::listen($event, new LazyModuleListener( + $module['class'], + $method + )); + } +} +``` + +### 3. Event-Driven Loading + +When an event fires, `LazyModuleListener` instantiates the module: + +```php +class LazyModuleListener +{ + public function __construct( + private string $moduleClass, + private string $method, + ) {} + + public function handle($event): void + { + // Module instantiated HERE, not before + $module = new $this->moduleClass(); + $module->{$this->method}($event); + } +} +``` + +## Request Types and Loading + +### Web Request + +``` +Request: GET /blog + ↓ +WebRoutesRegistering fired + ↓ +Only modules listening to WebRoutesRegistering loaded: + - BlogModule + - MarketingModule + ↓ +Admin/API modules never instantiated +``` + +### Admin Request + +``` +Request: GET /admin/posts + ↓ +AdminPanelBooting fired + ↓ +Only modules with admin routes loaded: + - BlogAdminModule + - CoreAdminModule + ↓ +Public web modules never instantiated +``` + +### API Request + +``` +Request: GET /api/v1/posts + ↓ +ApiRoutesRegistering fired + ↓ +Only modules with API endpoints loaded: + - BlogApiModule + - AuthModule + ↓ +Web/Admin views never loaded +``` + +### Console Command + +``` +Command: php artisan blog:publish + ↓ +ConsoleBooting fired + ↓ +Only modules with commands loaded: + - BlogModule (has blog:publish command) + ↓ +Web/Admin/API routes never registered +``` + +## Performance Impact + +### Memory Usage + +| Request Type | Traditional | Lazy Loading | Savings | +|--------------|-------------|--------------|---------| +| Web | 50 MB | 15 MB | 70% | +| Admin | 50 MB | 18 MB | 64% | +| API | 50 MB | 12 MB | 76% | +| Console | 50 MB | 10 MB | 80% | + +### Boot Time + +| Request Type | Traditional | Lazy Loading | Savings | +|--------------|-------------|--------------|---------| +| Web | 500ms | 150ms | 70% | +| Admin | 500ms | 180ms | 64% | +| API | 500ms | 120ms | 76% | +| Console | 500ms | 100ms | 80% | + +*Measurements from production application with 50+ modules* + +## Selective Loading + +### Only Listen to Needed Events + +Don't register for events you don't need: + +```php +// ✅ Good - API-only module +class Boot +{ + public static array $listens = [ + ApiRoutesRegistering::class => 'onApiRoutes', + ]; +} + +// ❌ Bad - unnecessary listeners +class Boot +{ + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', // Not needed + AdminPanelBooting::class => 'onAdmin', // Not needed + ApiRoutesRegistering::class => 'onApiRoutes', + ]; +} +``` + +### Conditional Loading + +Load features conditionally within event handlers: + +```php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Only load blog if enabled + if (config('modules.blog.enabled')) { + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } +} +``` + +## Deferred Service Providers + +Combine with Laravel's deferred providers for maximum laziness: + +```php +app->singleton(BlogService::class, function ($app) { + return new BlogService( + $app->make(PostRepository::class) + ); + }); + } + + public function provides(): array + { + // Only load this provider when BlogService is requested + return [BlogService::class]; + } +} +``` + +## Lazy Collections + +Use lazy collections for memory-efficient data processing: + +```php +// ✅ Good - lazy loading +Post::query() + ->published() + ->cursor() // Returns lazy collection + ->each(function ($post) { + ProcessPost::dispatch($post); + }); + +// ❌ Bad - loads all into memory +Post::query() + ->published() + ->get() // Loads everything + ->each(function ($post) { + ProcessPost::dispatch($post); + }); +``` + +## Lazy Relationships + +Defer relationship loading until needed: + +```php +// ✅ Good - lazy eager loading +$posts = Post::all(); + +if ($needsComments) { + $posts->load('comments'); +} + +// ❌ Bad - always loads comments +$posts = Post::with('comments')->get(); +``` + +## Route Lazy Loading + +Laravel 11+ supports route file lazy loading: + +```php +// routes/web.php +Route::middleware('web')->group(function () { + // Only load blog routes when /blog is accessed + Route::prefix('blog')->group(base_path('routes/blog.php')); +}); +``` + +## Cache Warming + +Warm caches during deployment, not during requests: + +```bash +# Deploy script +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan event:cache + +# Modules discovered once, cached +php artisan core:cache-modules +``` + +## Monitoring Lazy Loading + +### Track Module Loading + +Log when modules are instantiated: + +```php +class LazyModuleListener +{ + public function handle($event): void + { + $start = microtime(true); + + $module = new $this->moduleClass(); + $module->{$this->method}($event); + + $duration = (microtime(true) - $start) * 1000; + + Log::debug("Module loaded", [ + 'module' => $this->moduleClass, + 'event' => get_class($event), + 'duration_ms' => round($duration, 2), + ]); + } +} +``` + +### Analyze Module Usage + +Track which modules load for different request types: + +```bash +# Enable debug logging +APP_DEBUG=true LOG_LEVEL=debug + +# Make requests and check logs +tail -f storage/logs/laravel.log | grep "Module loaded" +``` + +## Debugging Lazy Loading + +### Force Load All Modules + +Disable lazy loading for debugging: + +```php +// config/core.php +'modules' => [ + 'lazy_loading' => env('MODULES_LAZY_LOADING', true), +], + +// .env +MODULES_LAZY_LOADING=false +``` + +### Check Module Load Order + +```php +Event::listen('*', function ($eventName, $data) { + if (str_starts_with($eventName, 'Core\\Events\\')) { + Log::debug("Event fired", ['event' => $eventName]); + } +}); +``` + +### Verify Listeners Registered + +```bash +php artisan event:list | grep "Core\\Events" +``` + +## Best Practices + +### 1. Keep Boot.php Lightweight + +Move heavy initialization to service providers: + +```php +// ✅ Good - lightweight Boot.php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + $event->routes(fn () => require __DIR__.'/Routes/web.php'); +} + +// ❌ Bad - heavy initialization in Boot.php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Don't do this in event handlers! + $this->registerServices(); + $this->loadViews(); + $this->publishAssets(); + $this->registerCommands(); + + $event->routes(fn () => require __DIR__.'/Routes/web.php'); +} +``` + +### 2. Avoid Global State in Modules + +Don't store state in module classes: + +```php +// ✅ Good - stateless +class Boot +{ + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } +} + +// ❌ Bad - stateful +class Boot +{ + private array $config = []; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $this->config = config('blog'); // Don't store state + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } +} +``` + +### 3. Use Dependency Injection + +Let the container handle dependencies: + +```php +// ✅ Good - DI in services +class BlogService +{ + public function __construct( + private PostRepository $posts, + private CacheManager $cache, + ) {} +} + +// ❌ Bad - manual instantiation +class BlogService +{ + public function __construct() + { + $this->posts = new PostRepository(); + $this->cache = new CacheManager(); + } +} +``` + +### 4. Defer Heavy Operations + +Don't perform expensive operations during boot: + +```php +// ✅ Good - defer to queue +public function onFrameworkBooted(FrameworkBooted $event): void +{ + dispatch(new WarmBlogCache())->afterResponse(); +} + +// ❌ Bad - expensive operation during boot +public function onFrameworkBooted(FrameworkBooted $event): void +{ + // Don't do this! + $posts = Post::with('comments', 'categories', 'tags')->get(); + Cache::put('blog:all-posts', $posts, 3600); +} +``` + +## Advanced Patterns + +### Lazy Singletons + +Register services as lazy singletons: + +```php +$this->app->singleton(BlogService::class, function ($app) { + return new BlogService( + $app->make(PostRepository::class) + ); +}); +``` + +Service only instantiated when first requested: + +```php +// BlogService not instantiated yet +$posts = Post::all(); + +// BlogService instantiated HERE +app(BlogService::class)->getRecentPosts(); +``` + +### Contextual Binding + +Bind different implementations based on context: + +```php +$this->app->when(ApiController::class) + ->needs(PostRepository::class) + ->give(CachedPostRepository::class); + +$this->app->when(AdminController::class) + ->needs(PostRepository::class) + ->give(LivePostRepository::class); +``` + +### Module Proxies + +Create proxies for optional modules: + +```php +class AnalyticsProxy +{ + public function track(string $event, array $data = []): void + { + // Only load analytics module if it exists + if (class_exists(Mod\Analytics\AnalyticsService::class)) { + app(AnalyticsService::class)->track($event, $data); + } + } +} +``` + +## Learn More + +- [Module System](/architecture/module-system) +- [Lifecycle Events](/architecture/lifecycle-events) +- [Performance Optimization](/architecture/performance) diff --git a/docs/build/php/architecture/lifecycle-events.md b/docs/build/php/architecture/lifecycle-events.md new file mode 100644 index 0000000..5114cb2 --- /dev/null +++ b/docs/build/php/architecture/lifecycle-events.md @@ -0,0 +1,610 @@ +# Lifecycle Events + +Core PHP Framework uses an event-driven architecture where modules declare interest in lifecycle events. This enables lazy loading and modular composition without tight coupling. + +## Overview + +The lifecycle event system provides extension points throughout the framework's boot process. Modules register listeners for specific events, and are only instantiated when those events fire. + +``` +Application Boot + ↓ +LifecycleEventProvider fires events + ↓ +LazyModuleListener intercepts events + ↓ +Module instantiated on-demand + ↓ +Event handler executes + ↓ +Module collects requests (routes, menus, etc.) + ↓ +LifecycleEventProvider processes requests +``` + +## Core Events + +### WebRoutesRegistering + +**Fired during:** Web route registration (early boot) + +**Purpose:** Register public-facing web routes and views + +**Use cases:** +- Marketing pages +- Public blog +- Documentation site +- Landing pages + +**Example:** + +```php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Register view namespace + $event->views('marketing', __DIR__.'/Views'); + + // Register routes + $event->routes(function () { + Route::get('/', [HomeController::class, 'index'])->name('home'); + Route::get('/pricing', [PricingController::class, 'index'])->name('pricing'); + Route::get('/contact', [ContactController::class, 'index'])->name('contact'); + }); + + // Register middleware + $event->middleware(['web', 'track-visitor']); +} +``` + +**Available Methods:** +- `views(string $namespace, string $path)` - Register view namespace +- `routes(Closure $callback)` - Register routes +- `middleware(array $middleware)` - Apply middleware to routes + +--- + +### AdminPanelBooting + +**Fired during:** Admin panel initialization + +**Purpose:** Register admin routes, menus, and dashboard widgets + +**Use cases:** +- Admin CRUD interfaces +- Dashboard widgets +- Settings pages +- Admin navigation + +**Example:** + +```php +public function onAdmin(AdminPanelBooting $event): void +{ + // Register admin routes + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + + // Register admin menu + $event->menu(new BlogMenuProvider()); + + // Register dashboard widget + $event->widget(new PostStatsWidget()); + + // Register settings page + $event->settings('blog', BlogSettingsPage::class); +} +``` + +**Available Methods:** +- `routes(Closure $callback)` - Register admin routes +- `menu(AdminMenuProvider $provider)` - Register menu items +- `widget(DashboardWidget $widget)` - Register dashboard widget +- `settings(string $key, string $class)` - Register settings page + +--- + +### ApiRoutesRegistering + +**Fired during:** API route registration + +**Purpose:** Register REST API endpoints + +**Use cases:** +- RESTful APIs +- Webhooks +- Third-party integrations +- Mobile app backends + +**Example:** + +```php +public function onApiRoutes(ApiRoutesRegistering $event): void +{ + $event->routes(function () { + Route::prefix('v1')->group(function () { + Route::apiResource('posts', PostApiController::class); + Route::get('posts/{post}/analytics', [PostApiController::class, 'analytics']); + }); + }); + + // API-specific middleware + $event->middleware(['api', 'auth:sanctum', 'scope:blog:read']); +} +``` + +**Available Methods:** +- `routes(Closure $callback)` - Register API routes +- `middleware(array $middleware)` - Apply middleware +- `version(string $version)` - Set API version prefix + +--- + +### ClientRoutesRegistering + +**Fired during:** Client route registration + +**Purpose:** Register authenticated client/dashboard routes + +**Use cases:** +- User dashboards +- Account settings +- Client portals +- Authenticated SPA routes + +**Example:** + +```php +public function onClientRoutes(ClientRoutesRegistering $event): void +{ + $event->views('dashboard', __DIR__.'/Views/Client'); + + $event->routes(function () { + Route::middleware(['auth', 'verified'])->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + Route::get('/account', [AccountController::class, 'show'])->name('account'); + Route::post('/account', [AccountController::class, 'update']); + }); + }); +} +``` + +**Available Methods:** +- `views(string $namespace, string $path)` - Register view namespace +- `routes(Closure $callback)` - Register routes +- `middleware(array $middleware)` - Apply middleware + +--- + +### ConsoleBooting + +**Fired during:** Console kernel initialization + +**Purpose:** Register Artisan commands + +**Use cases:** +- Custom commands +- Scheduled tasks +- Maintenance scripts +- Data migrations + +**Example:** + +```php +public function onConsole(ConsoleBooting $event): void +{ + // Register commands + $event->commands([ + PublishPostCommand::class, + ImportPostsCommand::class, + GenerateSitemapCommand::class, + ]); + + // Register scheduled tasks + $event->schedule(function (Schedule $schedule) { + $schedule->command(PublishScheduledPostsCommand::class) + ->hourly() + ->withoutOverlapping(); + + $schedule->command(GenerateSitemapCommand::class) + ->daily() + ->at('01:00'); + }); +} +``` + +**Available Methods:** +- `commands(array $commands)` - Register commands +- `schedule(Closure $callback)` - Define scheduled tasks + +--- + +### McpToolsRegistering + +**Fired during:** MCP server initialization + +**Purpose:** Register MCP (Model Context Protocol) tools for AI integrations + +**Use cases:** +- AI-powered features +- LLM tool integrations +- Automated workflows +- AI assistants + +**Example:** + +```php +public function onMcpTools(McpToolsRegistering $event): void +{ + $event->tools([ + GetPostTool::class, + CreatePostTool::class, + UpdatePostTool::class, + SearchPostsTool::class, + ]); + + // Register prompts + $event->prompts([ + GenerateBlogPostPrompt::class, + ]); + + // Register resources + $event->resources([ + BlogPostResource::class, + ]); +} +``` + +**Available Methods:** +- `tools(array $tools)` - Register MCP tools +- `prompts(array $prompts)` - Register prompt templates +- `resources(array $resources)` - Register resources + +--- + +### FrameworkBooted + +**Fired after:** All other lifecycle events have completed + +**Purpose:** Late-stage initialization and cross-module setup + +**Use cases:** +- Service registration +- Event listeners +- Observer registration +- Cache warming + +**Example:** + +```php +public function onFrameworkBooted(FrameworkBooted $event): void +{ + // Register event listeners + Event::listen(PostPublished::class, SendPostNotification::class); + Event::listen(PostViewed::class, IncrementViewCount::class); + + // Register model observers + Post::observe(PostObserver::class); + + // Register service + app()->singleton(BlogService::class, function ($app) { + return new BlogService( + $app->make(PostRepository::class), + $app->make(CategoryRepository::class) + ); + }); + + // Register policies + Gate::policy(Post::class, PostPolicy::class); +} +``` + +**Available Methods:** +- `service(string $abstract, Closure $factory)` - Register service +- `singleton(string $abstract, Closure $factory)` - Register singleton +- `listener(string $event, string $listener)` - Register event listener + +## Event Declaration + +Modules declare event listeners via the `$listens` property in `Boot.php`: + +```php + 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ApiRoutesRegistering::class => 'onApiRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void { } + public function onAdmin(AdminPanelBooting $event): void { } + public function onApiRoutes(ApiRoutesRegistering $event): void { } +} +``` + +## Lazy Loading + +Modules are **not** instantiated until an event they listen to is fired: + +```php +// Web request → Only WebRoutesRegistering listeners loaded +// API request → Only ApiRoutesRegistering listeners loaded +// Admin request → Only AdminPanelBooting listeners loaded +// Console command → Only ConsoleBooting listeners loaded +``` + +This dramatically reduces bootstrap time and memory usage. + +## Event Flow + +### 1. Module Discovery + +`ModuleScanner` scans configured paths for `Boot.php` files: + +```php +$scanner = new ModuleScanner(); +$modules = $scanner->scan([ + app_path('Core'), + app_path('Mod'), + app_path('Plug'), +]); +``` + +### 2. Listener Registration + +`ModuleRegistry` wires lazy listeners: + +```php +$registry = new ModuleRegistry(); +$registry->registerModules($modules); + +// Creates LazyModuleListener for each event-module pair +Event::listen(WebRoutesRegistering::class, LazyModuleListener::class); +``` + +### 3. Event Firing + +`LifecycleEventProvider` fires events at appropriate times: + +```php +// During route registration +$event = new WebRoutesRegistering(); +event($event); +``` + +### 4. Module Loading + +`LazyModuleListener` instantiates module on-demand: + +```php +public function handle($event): void +{ + $module = new $this->moduleClass(); // Module instantiated HERE + $module->{$this->method}($event); +} +``` + +### 5. Request Collection + +Modules collect requests during event handling: + +```php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Stored in $event->routeRequests + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + + // Stored in $event->viewRequests + $event->views('blog', __DIR__.'/Views'); +} +``` + +### 6. Request Processing + +`LifecycleEventProvider` processes collected requests: + +```php +foreach ($event->routeRequests as $request) { + Route::middleware($request['middleware']) + ->group($request['callback']); +} +``` + +## Custom Lifecycle Events + +You can create custom lifecycle events by extending `LifecycleEvent`: + +```php +providers[$name] = $class; + } + + public function getProviders(): array + { + return $this->providers; + } +} +``` + +Fire the event in your service provider: + +```php +$event = new PaymentProvidersRegistering(); +event($event); + +foreach ($event->getProviders() as $name => $class) { + PaymentGateway::register($name, $class); +} +``` + +Modules can listen to your custom event: + +```php +public static array $listens = [ + PaymentProvidersRegistering::class => 'onPaymentProviders', +]; + +public function onPaymentProviders(PaymentProvidersRegistering $event): void +{ + $event->provider('stripe', StripeProvider::class); +} +``` + +## Event Priorities + +Control event listener execution order: + +```php +Event::listen(WebRoutesRegistering::class, FirstModule::class, 100); +Event::listen(WebRoutesRegistering::class, SecondModule::class, 50); +Event::listen(WebRoutesRegistering::class, ThirdModule::class, 10); + +// Execution order: FirstModule → SecondModule → ThirdModule +``` + +## Testing Lifecycle Events + +Test that modules respond to events correctly: + +```php +onWebRoutes($event); + + $this->assertNotEmpty($event->routeRequests); + $this->assertNotEmpty($event->viewRequests); + } + + public function test_registers_admin_menu(): void + { + $event = new AdminPanelBooting(); + $boot = new Boot(); + + $boot->onAdmin($event); + + $this->assertNotEmpty($event->menuProviders); + } +} +``` + +## Best Practices + +### 1. Keep Event Handlers Focused + +Each event handler should only register resources related to that lifecycle phase: + +```php +// ✅ Good +public function onWebRoutes(WebRoutesRegistering $event): void +{ + $event->views('blog', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); +} + +// ❌ Bad - service registration belongs in FrameworkBooted +public function onWebRoutes(WebRoutesRegistering $event): void +{ + app()->singleton(BlogService::class, ...); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); +} +``` + +### 2. Use Dependency Injection + +Event handlers receive the event object - use it instead of facades: + +```php +// ✅ Good +public function onWebRoutes(WebRoutesRegistering $event): void +{ + $event->routes(function () { + Route::get('/blog', ...); + }); +} + +// ❌ Bad - bypasses event system +public function onWebRoutes(WebRoutesRegistering $event): void +{ + Route::get('/blog', ...); +} +``` + +### 3. Only Listen to Needed Events + +Don't register listeners for events you don't need: + +```php +// ✅ Good - API-only module +public static array $listens = [ + ApiRoutesRegistering::class => 'onApiRoutes', +]; + +// ❌ Bad - unnecessary listeners +public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ApiRoutesRegistering::class => 'onApiRoutes', +]; +``` + +### 4. Keep Boot.php Lightweight + +`Boot.php` should only coordinate - extract complex logic to dedicated classes: + +```php +// ✅ Good +public function onAdmin(AdminPanelBooting $event): void +{ + $event->menu(new BlogMenuProvider()); + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); +} + +// ❌ Bad - too much inline logic +public function onAdmin(AdminPanelBooting $event): void +{ + $event->menu([ + 'label' => 'Blog', + 'icon' => 'newspaper', + 'children' => [ + // ... 50 lines of menu configuration + ], + ]); +} +``` + +## Learn More + +- [Module System](/architecture/module-system) +- [Lazy Loading](/architecture/lazy-loading) +- [Creating Custom Events](/architecture/custom-events) diff --git a/docs/build/php/architecture/module-system.md b/docs/build/php/architecture/module-system.md new file mode 100644 index 0000000..f139386 --- /dev/null +++ b/docs/build/php/architecture/module-system.md @@ -0,0 +1,615 @@ +# Module System + +Core PHP Framework uses a modular monolith architecture where features are organized into self-contained modules that communicate through events and contracts. + +## What is a Module? + +A module is a self-contained feature with its own: + +- Routes (web, admin, API) +- Models and migrations +- Controllers and actions +- Views and assets +- Configuration +- Tests + +Modules declare their lifecycle event interests and are only loaded when needed. + +## Module Types + +### Core Modules (`app/Core/`) + +Foundation modules that provide framework functionality: + +``` +app/Core/ +├── Events/ # Lifecycle events +├── Module/ # Module system +├── Actions/ # Actions pattern +├── Config/ # Configuration system +├── Media/ # Media handling +└── Storage/ # Cache and storage +``` + +**Namespace:** `Core\` + +**Purpose:** Framework internals, shared utilities + +### Feature Modules (`app/Mod/`) + +Business domain modules: + +``` +app/Mod/ +├── Tenant/ # Multi-tenancy +├── Commerce/ # E-commerce features +├── Blog/ # Blogging +└── Analytics/ # Analytics +``` + +**Namespace:** `Mod\` + +**Purpose:** Application features + +### Website Modules (`app/Website/`) + +Site-specific implementations: + +``` +app/Website/ +├── Marketing/ # Marketing site +├── Docs/ # Documentation site +└── Support/ # Support portal +``` + +**Namespace:** `Website\` + +**Purpose:** Deployable websites/frontends + +### Plugin Modules (`app/Plug/`) + +Optional integrations: + +``` +app/Plug/ +├── Stripe/ # Stripe integration +├── Mailchimp/ # Mailchimp integration +└── Analytics/ # Analytics integrations +``` + +**Namespace:** `Plug\` + +**Purpose:** Third-party integrations, optional features + +## Module Structure + +Standard module structure created by `php artisan make:mod`: + +``` +app/Mod/Example/ +├── Boot.php # Module entry point +├── config.php # Module configuration +│ +├── Actions/ # Business logic +│ ├── CreateExample.php +│ └── UpdateExample.php +│ +├── Controllers/ # HTTP controllers +│ ├── Admin/ +│ │ └── ExampleController.php +│ └── ExampleController.php +│ +├── Models/ # Eloquent models +│ └── Example.php +│ +├── Migrations/ # Database migrations +│ └── 2026_01_01_create_examples_table.php +│ +├── Database/ +│ ├── Factories/ # Model factories +│ │ └── ExampleFactory.php +│ └── Seeders/ # Database seeders +│ └── ExampleSeeder.php +│ +├── Routes/ # Route definitions +│ ├── web.php # Public routes +│ ├── admin.php # Admin routes +│ └── api.php # API routes +│ +├── Views/ # Blade templates +│ ├── index.blade.php +│ └── show.blade.php +│ +├── Requests/ # Form requests +│ ├── StoreExampleRequest.php +│ └── UpdateExampleRequest.php +│ +├── Resources/ # API resources +│ └── ExampleResource.php +│ +├── Policies/ # Authorization policies +│ └── ExamplePolicy.php +│ +├── Events/ # Domain events +│ └── ExampleCreated.php +│ +├── Listeners/ # Event listeners +│ └── SendExampleNotification.php +│ +├── Jobs/ # Queued jobs +│ └── ProcessExample.php +│ +├── Services/ # Domain services +│ └── ExampleService.php +│ +├── Mcp/ # MCP tools +│ └── Tools/ +│ └── GetExampleTool.php +│ +└── Tests/ # Module tests + ├── Feature/ + │ └── ExampleTest.php + └── Unit/ + └── ExampleServiceTest.php +``` + +## Creating Modules + +### Using Artisan Commands + +```bash +# Create a feature module +php artisan make:mod Blog + +# Create a website module +php artisan make:website Marketing + +# Create a plugin module +php artisan make:plug Stripe +``` + +### Manual Creation + +1. Create directory structure +2. Create `Boot.php` with `$listens` array +3. Register lifecycle event handlers + +```php + 'onWebRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('example', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } +} +``` + +## Module Discovery + +### Auto-Discovery + +Modules are automatically discovered by scanning configured paths: + +```php +// config/core.php +'module_paths' => [ + app_path('Core'), + app_path('Mod'), + app_path('Plug'), +], +``` + +### Manual Registration + +Disable auto-discovery and register modules explicitly: + +```php +// config/core.php +'modules' => [ + 'auto_discover' => false, +], + +// app/Providers/AppServiceProvider.php +use Core\Module\ModuleRegistry; + +public function boot(): void +{ + $registry = app(ModuleRegistry::class); + + $registry->register(Mod\Blog\Boot::class); + $registry->register(Mod\Commerce\Boot::class); +} +``` + +## Module Configuration + +### Module-Level Configuration + +Each module can have a `config.php` file: + +```php + env('BLOG_POSTS_PER_PAGE', 12), + 'enable_comments' => env('BLOG_COMMENTS_ENABLED', true), + 'cache_duration' => env('BLOG_CACHE_DURATION', 3600), +]; +``` + +Access configuration: + +```php +$perPage = config('mod.blog.posts_per_page', 12); +``` + +### Publishing Configuration + +Allow users to customize module configuration: + +```php +// app/Mod/Blog/BlogServiceProvider.php +public function boot(): void +{ + $this->publishes([ + __DIR__.'/config.php' => config_path('mod/blog.php'), + ], 'blog-config'); +} +``` + +Users can then publish and customize: + +```bash +php artisan vendor:publish --tag=blog-config +``` + +## Inter-Module Communication + +### 1. Events (Recommended) + +Modules communicate via domain events: + +```php +// Mod/Blog/Events/PostPublished.php +class PostPublished +{ + public function __construct(public Post $post) {} +} + +// Mod/Blog/Actions/PublishPost.php +PostPublished::dispatch($post); + +// Mod/Analytics/Listeners/TrackPostPublished.php +Event::listen(PostPublished::class, TrackPostPublished::class); +``` + +### 2. Service Contracts + +Define contracts for shared functionality: + +```php +// Core/Contracts/NotificationService.php +interface NotificationService +{ + public function send(Notifiable $notifiable, Notification $notification): void; +} + +// Mod/Email/EmailNotificationService.php +class EmailNotificationService implements NotificationService +{ + public function send(Notifiable $notifiable, Notification $notification): void + { + // Implementation + } +} + +// Register in service provider +app()->bind(NotificationService::class, EmailNotificationService::class); + +// Use in other modules +app(NotificationService::class)->send($user, $notification); +``` + +### 3. Facades + +Create facades for frequently used services: + +```php +// Mod/Blog/Facades/Blog.php +class Blog extends Facade +{ + protected static function getFacadeAccessor() + { + return BlogService::class; + } +} + +// Usage +Blog::getRecentPosts(10); +Blog::findBySlug('example-post'); +``` + +## Module Dependencies + +### Declaring Dependencies + +Use PHP attributes to declare module dependencies: + +```php +isLoaded(Mod\Blog\Boot::class)) { + // Blog module is available +} +``` + +## Module Isolation + +### Database Isolation + +Use workspace scoping for multi-tenant isolation: + +```php +use Core\Mod\Tenant\Concerns\BelongsToWorkspace; + +class Post extends Model +{ + use BelongsToWorkspace; +} + +// Queries automatically scoped to current workspace +Post::all(); // Only returns posts for current workspace +``` + +### Cache Isolation + +Use workspace-scoped caching: + +```php +use Core\Mod\Tenant\Concerns\HasWorkspaceCache; + +class Post extends Model +{ + use BelongsToWorkspace, HasWorkspaceCache; +} + +// Cache isolated per workspace +Post::forWorkspaceCached($workspace, 600); +``` + +### Route Isolation + +Separate route files by context: + +```php +// Routes/web.php - Public routes +Route::get('/blog', [BlogController::class, 'index']); + +// Routes/admin.php - Admin routes +Route::resource('posts', PostController::class); + +// Routes/api.php - API routes +Route::apiResource('posts', PostApiController::class); +``` + +## Module Testing + +### Feature Tests + +Test module functionality end-to-end: + +```php +published()->count(3)->create(); + + $response = $this->get('/blog'); + + $response->assertStatus(200); + $response->assertViewHas('posts'); + } +} +``` + +### Unit Tests + +Test module services and actions: + +```php +create(['published_at' => null]); + + PublishPost::run($post); + + $this->assertNotNull($post->fresh()->published_at); + } +} +``` + +### Module Isolation Tests + +Test that module doesn't leak dependencies: + +```php +public function test_module_works_without_optional_dependencies(): void +{ + // Simulate missing optional module + app()->forgetInstance(Mod\Analytics\AnalyticsService::class); + + $response = $this->get('/blog'); + + $response->assertStatus(200); +} +``` + +## Best Practices + +### 1. Keep Modules Focused + +Each module should have a single, well-defined responsibility: + +``` +✅ Good: Mod\Blog (blogging features) +✅ Good: Mod\Comments (commenting system) +❌ Bad: Mod\BlogAndCommentsAndTags (too broad) +``` + +### 2. Use Explicit Dependencies + +Don't assume other modules exist: + +```php +// ✅ Good +if (class_exists(Mod\Analytics\AnalyticsService::class)) { + app(AnalyticsService::class)->track($event); +} + +// ❌ Bad +app(AnalyticsService::class)->track($event); // Crashes if not available +``` + +### 3. Avoid Circular Dependencies + +``` +✅ Good: Blog → Comments (one-way) +❌ Bad: Blog ⟷ Comments (circular) +``` + +### 4. Use Interfaces for Contracts + +Define interfaces for inter-module communication: + +```php +// Core/Contracts/SearchProvider.php +interface SearchProvider +{ + public function search(string $query): Collection; +} + +// Mod/Blog/BlogSearchProvider.php +class BlogSearchProvider implements SearchProvider +{ + // Implementation +} +``` + +### 5. Version Your APIs + +If modules expose APIs, version them: + +```php +// Routes/api.php +Route::prefix('v1')->group(function () { + Route::apiResource('posts', V1\PostController::class); +}); + +Route::prefix('v2')->group(function () { + Route::apiResource('posts', V2\PostController::class); +}); +``` + +## Troubleshooting + +### Module Not Loading + +Check module is in configured path: + +```bash +# Verify path exists +ls -la app/Mod/YourModule + +# Check Boot.php exists +cat app/Mod/YourModule/Boot.php + +# Verify $listens array +grep "listens" app/Mod/YourModule/Boot.php +``` + +### Routes Not Registered + +Ensure event handler calls `$event->routes()`: + +```php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Don't forget this! + $event->routes(fn () => require __DIR__.'/Routes/web.php'); +} +``` + +### Views Not Found + +Register view namespace: + +```php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Register view namespace + $event->views('blog', __DIR__.'/Views'); +} +``` + +Then use namespaced views: + +```php +return view('blog::index'); // Not just 'index' +``` + +## Learn More + +- [Lifecycle Events](/architecture/lifecycle-events) +- [Lazy Loading](/architecture/lazy-loading) +- [Multi-Tenancy](/patterns-guide/multi-tenancy) +- [Actions Pattern](/patterns-guide/actions) diff --git a/docs/build/php/architecture/multi-tenancy.md b/docs/build/php/architecture/multi-tenancy.md new file mode 100644 index 0000000..cccc412 --- /dev/null +++ b/docs/build/php/architecture/multi-tenancy.md @@ -0,0 +1,600 @@ +# Multi-Tenancy Architecture + +Core PHP Framework provides robust multi-tenant isolation using workspace-scoped data. All tenant data is automatically isolated without manual filtering. + +## Overview + +Multi-tenancy ensures that users in one workspace (tenant) cannot access data from another workspace. Core PHP implements this through: + +- Automatic query scoping via global scopes +- Workspace context validation +- Workspace-scoped caching +- Request-level workspace resolution + +## Workspace Model + +The `Workspace` model represents a tenant: + +```php + 'boolean', + 'settings' => 'array', + ]; + + public function users() + { + return $this->hasMany(User::class); + } + + public function isSuspended(): bool + { + return $this->is_suspended; + } +} +``` + +## Making Models Workspace-Scoped + +### Basic Usage + +Add the `BelongsToWorkspace` trait to any model: + +```php + 'Example', + 'content' => 'Content', + // workspace_id added automatically +]); + +// Cannot access posts from other workspaces +$post = Post::find(999); // null if belongs to different workspace +``` + +### Migration + +Add `workspace_id` foreign key to tables: + +```php +Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('content'); + $table->timestamps(); + + $table->index(['workspace_id', 'created_at']); +}); +``` + +## Workspace Scope + +The `WorkspaceScope` global scope enforces data isolation: + +```php +getCurrentWorkspace()) { + $builder->where("{$model->getTable()}.workspace_id", $workspace->id); + } elseif ($this->isStrictMode()) { + throw new MissingWorkspaceContextException(); + } + } + + // ... +} +``` + +### Strict Mode + +Strict mode throws exceptions if workspace context is missing: + +```php +// config/core.php +'workspace' => [ + 'strict_mode' => env('WORKSPACE_STRICT_MODE', true), +], +``` + +**Development:** Set to `true` to catch missing context bugs early +**Production:** Keep at `true` for security + +### Bypassing Workspace Scope + +Sometimes you need to query across workspaces: + +```php +// Query all workspaces (use with caution!) +Post::acrossWorkspaces()->get(); + +// Temporarily disable strict mode +WorkspaceScope::withoutStrictMode(function () { + return Post::all(); +}); + +// Query specific workspace +Post::forWorkspace($otherWorkspace)->get(); +``` + +## Workspace Context + +### Setting Workspace Context + +The current workspace is typically set via middleware: + +```php +extractSubdomain($request); + $workspace = Workspace::where('slug', $subdomain)->firstOrFail(); + + // Set workspace context for this request + app()->instance('current.workspace', $workspace); + + return $next($request); + } +} +``` + +### Retrieving Current Workspace + +```php +// Via helper +$workspace = workspace(); + +// Via container +$workspace = app('current.workspace'); + +// Via auth user +$workspace = auth()->user()->workspace; +``` + +### Middleware + +Apply workspace validation middleware to routes: + +```php +// Ensure workspace context exists +Route::middleware(RequireWorkspaceContext::class)->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index']); +}); +``` + +## Workspace-Scoped Caching + +### Overview + +Workspace-scoped caching ensures cache isolation between tenants: + +```php +// Cache key: workspace:123:posts:recent +// Different workspace = different cache key +$posts = Post::forWorkspaceCached($workspace, 600); +``` + +### HasWorkspaceCache Trait + +Add workspace caching to models: + +```php + [ + 'enabled' => env('WORKSPACE_CACHE_ENABLED', true), + 'ttl' => env('WORKSPACE_CACHE_TTL', 3600), + 'use_tags' => env('WORKSPACE_CACHE_USE_TAGS', true), + 'prefix' => 'workspace', +], +``` + +### Cache Tags (Recommended) + +Use cache tags for granular invalidation: + +```php +// Store with tags +Cache::tags(['workspace:'.$workspace->id, 'posts']) + ->put('recent-posts', $posts, 600); + +// Invalidate all posts caches for workspace +Cache::tags(['workspace:'.$workspace->id, 'posts'])->flush(); + +// Invalidate everything for workspace +Cache::tags(['workspace:'.$workspace->id])->flush(); +``` + +## Database Isolation Strategies + +### Shared Database (Recommended) + +Single database with `workspace_id` column: + +**Pros:** +- Simple deployment +- Easy backups +- Cross-workspace queries possible +- Cost-effective + +**Cons:** +- Requires careful scoping +- One bad query can leak data + +```php +// All tables have workspace_id +Schema::create('posts', function (Blueprint $table) { + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + // ... +}); +``` + +### Separate Databases (Advanced) + +Each workspace has its own database: + +**Pros:** +- Complete isolation +- Better security +- Easier compliance + +**Cons:** +- Complex migrations +- Higher operational cost +- No cross-workspace queries + +```php +// Dynamically switch database connection +config([ + 'database.connections.workspace' => [ + 'database' => "workspace_{$workspace->id}", + // ... + ], +]); + +DB::connection('workspace')->table('posts')->get(); +``` + +## Security Best Practices + +### 1. Always Use WorkspaceScope + +Never bypass workspace scoping in application code: + +```php +// ✅ Good +$posts = Post::all(); + +// ❌ Bad - security vulnerability! +$posts = Post::withoutGlobalScope(WorkspaceScope::class)->get(); +``` + +### 2. Validate Workspace Context + +Always validate workspace exists and isn't suspended: + +```php +public function handle(Request $request, Closure $next) +{ + $workspace = workspace(); + + if (! $workspace) { + throw new MissingWorkspaceContextException(); + } + + if ($workspace->isSuspended()) { + abort(403, 'Workspace suspended'); + } + + return $next($request); +} +``` + +### 3. Use Policies for Authorization + +Combine workspace scoping with Laravel policies: + +```php +class PostPolicy +{ + public function update(User $user, Post $post): bool + { + // Workspace scope ensures $post belongs to current workspace + // Policy checks user has permission within that workspace + return $user->can('edit-posts'); + } +} +``` + +### 4. Audit Workspace Access + +Log workspace access for security auditing: + +```php +activity() + ->causedBy($user) + ->performedOn($workspace) + ->withProperties(['action' => 'accessed']) + ->log('Workspace accessed'); +``` + +### 5. Test Cross-Workspace Isolation + +Write tests to verify data isolation: + +```php +public function test_cannot_access_other_workspace_data(): void +{ + $workspace1 = Workspace::factory()->create(); + $workspace2 = Workspace::factory()->create(); + + $post = Post::factory()->for($workspace1)->create(); + + // Set context to workspace2 + app()->instance('current.workspace', $workspace2); + + // Should not find post from workspace1 + $this->assertNull(Post::find($post->id)); +} +``` + +## Cross-Workspace Operations + +### Admin Operations + +Admins sometimes need cross-workspace access: + +```php +// Check if user is super admin +if (auth()->user()->isSuperAdmin()) { + // Allow cross-workspace queries + $allPosts = Post::acrossWorkspaces() + ->where('published_at', '>', now()->subDays(7)) + ->get(); +} +``` + +### Reporting + +Generate reports across workspaces: + +```php +class GenerateSystemReportJob +{ + public function handle(): void + { + $stats = WorkspaceScope::withoutStrictMode(function () { + return [ + 'total_posts' => Post::count(), + 'total_users' => User::count(), + 'by_workspace' => Workspace::withCount('posts')->get(), + ]; + }); + + // ... + } +} +``` + +### Migrations + +Migrations run without workspace context: + +```php +public function up(): void +{ + WorkspaceScope::withoutStrictMode(function () { + // Migrate data across all workspaces + Post::chunk(100, function ($posts) { + foreach ($posts as $post) { + $post->update(['migrated' => true]); + } + }); + }); +} +``` + +## Performance Optimization + +### Eager Loading + +Include workspace relation when needed: + +```php +// ✅ Good +$posts = Post::with('workspace')->get(); + +// ❌ Bad - N+1 queries +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->workspace->name; // N+1 +} +``` + +### Index Optimization + +Add composite indexes for workspace queries: + +```php +$table->index(['workspace_id', 'created_at']); +$table->index(['workspace_id', 'status']); +$table->index(['workspace_id', 'user_id']); +``` + +### Partition Tables (Advanced) + +For very large datasets, partition by workspace_id: + +```sql +CREATE TABLE posts ( + id BIGINT, + workspace_id BIGINT NOT NULL, + -- ... +) PARTITION BY HASH(workspace_id) PARTITIONS 10; +``` + +## Monitoring + +### Track Workspace Usage + +Monitor workspace-level metrics: + +```php +// Query count per workspace +DB::listen(function ($query) { + $workspace = workspace(); + if ($workspace) { + Redis::zincrby('workspace:queries', 1, $workspace->id); + } +}); + +// Get top workspaces by query count +$top = Redis::zrevrange('workspace:queries', 0, 10, 'WITHSCORES'); +``` + +### Cache Hit Rates + +Track cache effectiveness per workspace: + +```php +WorkspaceCacheManager::trackHit($workspace); +WorkspaceCacheManager::trackMiss($workspace); + +$hitRate = WorkspaceCacheManager::getHitRate($workspace); +``` + +## Troubleshooting + +### Missing Workspace Context + +``` +MissingWorkspaceContextException: Workspace context required but not set +``` + +**Solution:** Ensure middleware sets workspace context: + +```php +Route::middleware(RequireWorkspaceContext::class)->group(/*...*/); +``` + +### Wrong Workspace Data + +``` +User sees data from different workspace +``` + +**Solution:** Check workspace is set correctly: + +```php +dd(workspace()); // Verify correct workspace +``` + +### Cache Bleeding + +``` +Cached data appearing across workspaces +``` + +**Solution:** Ensure cache keys include workspace ID: + +```php +// ✅ Good +$key = "workspace:{$workspace->id}:posts:recent"; + +// ❌ Bad +$key = "posts:recent"; // Same key for all workspaces! +``` + +## Learn More + +- [Workspace Caching](/patterns-guide/workspace-caching) +- [Security Best Practices](/security/overview) +- [Testing Multi-Tenancy](/testing/multi-tenancy) diff --git a/docs/build/php/architecture/performance.md b/docs/build/php/architecture/performance.md new file mode 100644 index 0000000..171c357 --- /dev/null +++ b/docs/build/php/architecture/performance.md @@ -0,0 +1,513 @@ +# Performance Optimization + +Best practices and techniques for optimizing Core PHP Framework applications. + +## Database Optimization + +### Eager Loading + +Prevent N+1 queries with eager loading: + +```php +// ❌ Bad - N+1 queries +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; // Query per post + echo $post->category->name; // Another query per post +} + +// ✅ Good - 3 queries total +$posts = Post::with(['author', 'category'])->get(); +foreach ($posts as $post) { + echo $post->author->name; + echo $post->category->name; +} +``` + +### Query Optimization + +```php +// ❌ Bad - fetches all columns +$posts = Post::all(); + +// ✅ Good - only needed columns +$posts = Post::select(['id', 'title', 'created_at'])->get(); + +// ✅ Good - count instead of loading all +$count = Post::count(); + +// ❌ Bad +$count = Post::all()->count(); + +// ✅ Good - exists check +$exists = Post::where('status', 'published')->exists(); + +// ❌ Bad +$exists = Post::where('status', 'published')->count() > 0; +``` + +### Chunking Large Datasets + +```php +// ❌ Bad - loads everything into memory +$posts = Post::all(); +foreach ($posts as $post) { + $this->process($post); +} + +// ✅ Good - process in chunks +Post::chunk(1000, function ($posts) { + foreach ($posts as $post) { + $this->process($post); + } +}); + +// ✅ Better - lazy collection +Post::lazy()->each(function ($post) { + $this->process($post); +}); +``` + +### Database Indexes + +```php +// Migration +Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->string('slug')->unique(); // Index for lookups + $table->string('status')->index(); // Index for filtering + $table->foreignId('workspace_id')->constrained(); // Foreign key index + + // Composite index for common query + $table->index(['workspace_id', 'status', 'created_at']); +}); +``` + +## Caching Strategies + +### Model Caching + +```php +use Illuminate\Support\Facades\Cache; + +class Post extends Model +{ + public static function findCached(int $id): ?self + { + return Cache::remember( + "posts.{$id}", + now()->addHour(), + fn () => self::find($id) + ); + } + + protected static function booted(): void + { + // Invalidate cache on update + static::updated(fn ($post) => Cache::forget("posts.{$post->id}")); + static::deleted(fn ($post) => Cache::forget("posts.{$post->id}")); + } +} +``` + +### Query Result Caching + +```php +// ❌ Bad - no caching +public function getPopularPosts() +{ + return Post::where('views', '>', 1000) + ->orderByDesc('views') + ->limit(10) + ->get(); +} + +// ✅ Good - cached for 1 hour +public function getPopularPosts() +{ + return Cache::remember('posts.popular', 3600, function () { + return Post::where('views', '>', 1000) + ->orderByDesc('views') + ->limit(10) + ->get(); + }); +} +``` + +### Cache Tags + +```php +// Tag cache for easy invalidation +Cache::tags(['posts', 'popular'])->put('popular-posts', $posts, 3600); + +// Clear all posts cache +Cache::tags('posts')->flush(); +``` + +### Redis Caching + +```php +// config/cache.php +'default' => env('CACHE_DRIVER', 'redis'), + +'stores' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'cache', + 'lock_connection' => 'default', + ], +], +``` + +## Asset Optimization + +### CDN Integration + +```php +// Use CDN helper +Hero + +// With transformations + +``` + +### Image Optimization + +```php +use Core\Media\Image\ImageOptimizer; + +$optimizer = app(ImageOptimizer::class); + +// Automatic optimization +$optimizer->optimize($imagePath, [ + 'quality' => 85, + 'max_width' => 1920, + 'strip_exif' => true, + 'convert_to_webp' => true, +]); +``` + +### Lazy Loading + +```blade +{{-- Lazy load images --}} +... + +{{-- Lazy load thumbnails --}} +... +``` + +## Code Optimization + +### Lazy Loading Modules + +Modules only load when their events fire: + +```php +// Module Boot.php +public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', +]; + +// Only loads when WebRoutesRegistering fires +// Saves memory and boot time +``` + +### Deferred Service Providers + +```php +app->singleton(AnalyticsService::class); + } + + public function provides(): array + { + return [AnalyticsService::class]; + } +} +``` + +### Configuration Caching + +```bash +# Cache configuration +php artisan config:cache + +# Clear config cache +php artisan config:clear +``` + +### Route Caching + +```bash +# Cache routes +php artisan route:cache + +# Clear route cache +php artisan route:clear +``` + +## Queue Optimization + +### Queue Heavy Operations + +```php +// ❌ Bad - slow request +public function store(Request $request) +{ + $post = Post::create($request->validated()); + + // Slow operations in request cycle + $this->generateThumbnails($post); + $this->generateOgImage($post); + $this->notifySubscribers($post); + + return redirect()->route('posts.show', $post); +} + +// ✅ Good - queued +public function store(Request $request) +{ + $post = Post::create($request->validated()); + + // Queue heavy operations + GenerateThumbnails::dispatch($post); + GenerateOgImage::dispatch($post); + NotifySubscribers::dispatch($post); + + return redirect()->route('posts.show', $post); +} +``` + +### Job Batching + +```php +use Illuminate\Bus\Batch; +use Illuminate\Support\Facades\Bus; + +Bus::batch([ + new ProcessPost($post1), + new ProcessPost($post2), + new ProcessPost($post3), +])->then(function (Batch $batch) { + // All jobs completed successfully +})->catch(function (Batch $batch, Throwable $e) { + // First batch job failure +})->finally(function (Batch $batch) { + // Batch finished +})->dispatch(); +``` + +## Livewire Optimization + +### Lazy Loading Components + +```blade +{{-- Load component when visible --}} + + +{{-- Load on interaction --}} + +``` + +### Polling Optimization + +```php +// ❌ Bad - polls every 1s +
+ {{ $count }} users online +
+ +// ✅ Good - polls every 30s +
+ {{ $count }} users online +
+ +// ✅ Better - poll only when visible +
+ {{ $count }} users online +
+``` + +### Debouncing + +```blade +{{-- Debounce search input --}} + +``` + +## Response Optimization + +### HTTP Caching + +```php +// Cache response for 1 hour +return response($content) + ->header('Cache-Control', 'public, max-age=3600'); + +// ETag caching +$etag = md5($content); + +if ($request->header('If-None-Match') === $etag) { + return response('', 304); +} + +return response($content) + ->header('ETag', $etag); +``` + +### Gzip Compression + +```php +// config/app.php (handled by middleware) +'middleware' => [ + \Illuminate\Http\Middleware\HandleCors::class, + \Illuminate\Http\Middleware\ValidatePostSize::class, + \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, +], +``` + +### Response Streaming + +```php +// Stream large files +return response()->streamDownload(function () { + $handle = fopen('large-file.csv', 'r'); + while (!feof($handle)) { + echo fread($handle, 8192); + flush(); + } + fclose($handle); +}, 'download.csv'); +``` + +## Monitoring Performance + +### Query Logging + +```php +// Enable query log in development +if (app()->isLocal()) { + DB::enableQueryLog(); +} + +// View queries +dd(DB::getQueryLog()); +``` + +### Telescope + +```bash +# Install Laravel Telescope +composer require laravel/telescope --dev + +php artisan telescope:install +php artisan migrate +``` + +### Clockwork + +```bash +# Install Clockwork +composer require itsgoingd/clockwork --dev +``` + +### Application Performance + +```php +// Measure execution time +$start = microtime(true); + +// Your code here + +$duration = (microtime(true) - $start) * 1000; // milliseconds +Log::info("Operation took {$duration}ms"); +``` + +## Load Testing + +### Using Apache Bench + +```bash +# 1000 requests, 10 concurrent +ab -n 1000 -c 10 https://example.com/ +``` + +### Using k6 + +```javascript +// load-test.js +import http from 'k6/http'; + +export let options = { + vus: 10, // 10 virtual users + duration: '30s', +}; + +export default function () { + http.get('https://example.com/api/posts'); +} +``` + +```bash +k6 run load-test.js +``` + +## Best Practices Checklist + +### Database +- [ ] Use eager loading to prevent N+1 queries +- [ ] Add indexes to frequently queried columns +- [ ] Use `select()` to limit columns +- [ ] Chunk large datasets +- [ ] Use `exists()` instead of `count() > 0` + +### Caching +- [ ] Cache expensive query results +- [ ] Use Redis for session/cache storage +- [ ] Implement cache tags for easy invalidation +- [ ] Set appropriate cache TTLs + +### Assets +- [ ] Optimize images before uploading +- [ ] Use CDN for static assets +- [ ] Enable lazy loading for images +- [ ] Generate responsive image sizes + +### Code +- [ ] Queue heavy operations +- [ ] Use lazy loading for modules +- [ ] Cache configuration and routes +- [ ] Implement deferred service providers + +### Frontend +- [ ] Minimize JavaScript bundle size +- [ ] Debounce user input +- [ ] Use lazy loading for Livewire components +- [ ] Optimize polling intervals + +### Monitoring +- [ ] Use Telescope/Clockwork in development +- [ ] Log slow queries +- [ ] Monitor cache hit rates +- [ ] Track job queue performance + +## Learn More + +- [Configuration →](/packages/core/configuration) +- [CDN Integration →](/packages/core/cdn) +- [Media Processing →](/packages/core/media) diff --git a/docs/build/php/cdn.md b/docs/build/php/cdn.md new file mode 100644 index 0000000..d3bc78f --- /dev/null +++ b/docs/build/php/cdn.md @@ -0,0 +1,399 @@ +# CDN Integration + +Core PHP provides unified CDN integration for BunnyCDN and Cloudflare with automatic asset offloading, URL generation, and cache management. + +## Configuration + +```php +// config/cdn.php +return [ + 'driver' => env('CDN_DRIVER', 'bunnycdn'), + + 'bunnycdn' => [ + 'api_key' => env('BUNNY_API_KEY'), + 'storage_zone' => env('BUNNY_STORAGE_ZONE'), + 'storage_password' => env('BUNNY_STORAGE_PASSWORD'), + 'cdn_url' => env('BUNNY_CDN_URL'), + 'pull_zone_id' => env('BUNNY_PULL_ZONE_ID'), + ], + + 'cloudflare' => [ + 'zone_id' => env('CLOUDFLARE_ZONE_ID'), + 'api_token' => env('CLOUDFLARE_API_TOKEN'), + 'cdn_url' => env('CLOUDFLARE_CDN_URL'), + ], + + 'offload' => [ + 'enabled' => env('CDN_OFFLOAD_ENABLED', false), + 'paths' => ['public/images', 'public/media', 'storage/app/public'], + ], +]; +``` + +## Basic Usage + +### Generating CDN URLs + +```php +use Core\Cdn\Facades\Cdn; + +// Generate CDN URL +$url = Cdn::url('images/photo.jpg'); +// https://cdn.example.com/images/photo.jpg + +// With transformation parameters +$url = Cdn::url('images/photo.jpg', [ + 'width' => 800, + 'quality' => 85, +]); +``` + +### Helper Function + +```php +// Global helper +$url = cdn_url('images/photo.jpg'); + +// In Blade templates +Photo +``` + +### Storing Files + +```php +// Upload file to CDN +$path = Cdn::store($uploadedFile, 'media'); + +// Store with custom filename +$path = Cdn::store($uploadedFile, 'media', 'custom-name.jpg'); + +// Store from contents +$path = Cdn::put('path/file.txt', $contents); +``` + +### Deleting Files + +```php +// Delete single file +Cdn::delete('media/photo.jpg'); + +// Delete multiple files +Cdn::delete(['media/photo1.jpg', 'media/photo2.jpg']); + +// Delete directory +Cdn::deleteDirectory('media/old'); +``` + +## Cache Purging + +### Purge Single File + +```php +// Purge specific file from CDN cache +Cdn::purge('images/photo.jpg'); +``` + +### Purge Multiple Files + +```php +// Purge multiple files +Cdn::purge([ + 'images/photo1.jpg', + 'images/photo2.jpg', +]); +``` + +### Purge by Pattern + +```php +// Purge all images +Cdn::purge('images/*'); + +// Purge all JPEGs +Cdn::purge('**/*.jpg'); +``` + +### Purge Everything + +```php +// Purge entire CDN cache (use sparingly!) +Cdn::purgeAll(); +``` + +## Asset Offloading + +Automatically offload existing assets to CDN: + +```bash +# Offload public disk +php artisan storage:offload --disk=public + +# Offload specific path +php artisan storage:offload --path=public/images + +# Dry run (preview without uploading) +php artisan storage:offload --dry-run +``` + +### Programmatic Offloading + +```php +use Core\Cdn\Services\AssetPipeline; + +$pipeline = app(AssetPipeline::class); + +// Offload directory +$result = $pipeline->offload('public/images', [ + 'extensions' => ['jpg', 'png', 'gif', 'webp'], + 'min_size' => 1024, // Only files > 1KB +]); + +echo "Uploaded: {$result['uploaded']} files\n"; +echo "Skipped: {$result['skipped']} files\n"; +``` + +## URL Builder + +Advanced URL construction with transformations: + +```php +use Core\Cdn\Services\CdnUrlBuilder; + +$builder = app(CdnUrlBuilder::class); + +$url = $builder->build('images/photo.jpg', [ + // Dimensions + 'width' => 800, + 'height' => 600, + 'aspect_ratio' => '16:9', + + // Quality + 'quality' => 85, + 'format' => 'webp', + + // Effects + 'blur' => 10, + 'brightness' => 1.2, + 'contrast' => 1.1, + + // Cropping + 'crop' => 'center', + 'gravity' => 'face', +]); +``` + +## BunnyCDN Specific + +### Pull Zone Management + +```php +use Core\Cdn\Services\BunnyCdnService; + +$bunny = app(BunnyCdnService::class); + +// Get pull zone info +$pullZone = $bunny->getPullZone($pullZoneId); + +// Add/remove hostnames +$bunny->addHostname($pullZoneId, 'cdn.example.com'); +$bunny->removeHostname($pullZoneId, 'cdn.example.com'); + +// Enable/disable cache +$bunny->setCacheEnabled($pullZoneId, true); +``` + +### Storage Zone Operations + +```php +use Core\Cdn\Services\BunnyStorageService; + +$storage = app(BunnyStorageService::class); + +// List files +$files = $storage->list('media/'); + +// Get file info +$info = $storage->getFileInfo('media/photo.jpg'); + +// Download file +$contents = $storage->download('media/photo.jpg'); +``` + +## Cloudflare Specific + +### Zone Management + +```php +use Core\Cdn\Services\FluxCdnService; + +$cloudflare = app(FluxCdnService::class); + +// Purge cache by URLs +$cloudflare->purgePaths([ + 'https://example.com/images/photo.jpg', + 'https://example.com/styles/app.css', +]); + +// Purge by cache tags +$cloudflare->purgeTags(['images', 'media']); + +// Purge everything +$cloudflare->purgeEverything(); +``` + +## Testing + +### Fake CDN + +```php +use Core\Cdn\Facades\Cdn; + +class UploadTest extends TestCase +{ + public function test_uploads_file(): void + { + Cdn::fake(); + + $response = $this->post('/upload', [ + 'file' => UploadedFile::fake()->image('photo.jpg'), + ]); + + Cdn::assertStored('media/photo.jpg'); + } +} +``` + +### Assert Operations + +```php +// Assert file was stored +Cdn::assertStored('path/file.jpg'); + +// Assert file was deleted +Cdn::assertDeleted('path/file.jpg'); + +// Assert cache was purged +Cdn::assertPurged('path/file.jpg'); + +// Assert nothing was stored +Cdn::assertNothingStored(); +``` + +## Performance + +### URL Caching + +CDN URLs are cached to avoid repeated lookups: + +```php +// URLs cached for 1 hour +$url = Cdn::url('images/photo.jpg'); // Generates URL + caches +$url = Cdn::url('images/photo.jpg'); // Returns from cache +``` + +### Batch Operations + +```php +// Batch delete (single API call) +Cdn::delete([ + 'media/photo1.jpg', + 'media/photo2.jpg', + 'media/photo3.jpg', +]); + +// Batch purge (single API call) +Cdn::purge([ + 'images/*.jpg', + 'styles/*.css', +]); +``` + +## Best Practices + +### 1. Use Helper in Blade + +```blade +{{-- ✅ Good --}} +Photo + +{{-- ❌ Bad - relative path --}} +Photo +``` + +### 2. Offload Static Assets + +```php +// ✅ Good - offload after upload +public function store(Request $request) +{ + $path = $request->file('image')->store('media'); + + // Offload to CDN immediately + Cdn::store($path); + + return $path; +} +``` + +### 3. Purge After Updates + +```php +// ✅ Good - purge on update +public function update(Request $request, Media $media) +{ + $oldPath = $media->path; + + $media->update($request->validated()); + + // Purge old file from cache + Cdn::purge($oldPath); +} +``` + +### 4. Use Transformations + +```php +// ✅ Good - CDN transforms image + + +// ❌ Bad - transform server-side + +``` + +## Troubleshooting + +### Files Not Appearing + +```bash +# Verify CDN credentials +php artisan tinker +>>> Cdn::store(UploadedFile::fake()->image('test.jpg'), 'test') + +# Check CDN dashboard for new files +``` + +### Purge Not Working + +```bash +# Verify pull zone ID +php artisan tinker +>>> config('cdn.bunnycdn.pull_zone_id') + +# Manual purge via dashboard +``` + +### URLs Not Resolving + +```php +// Check CDN URL configuration +echo config('cdn.bunnycdn.cdn_url'); + +// Verify file exists on CDN +$exists = Cdn::exists('path/file.jpg'); +``` + +## Learn More + +- [Media Processing →](/core/media) +- [Storage Configuration →](/guide/configuration#storage) +- [Asset Pipeline →](/core/media#asset-pipeline) diff --git a/docs/build/php/configuration.md b/docs/build/php/configuration.md new file mode 100644 index 0000000..3074caf --- /dev/null +++ b/docs/build/php/configuration.md @@ -0,0 +1,474 @@ +# Configuration Management + +Core PHP Framework provides a powerful multi-profile configuration system with versioning, rollback capabilities, and environment-specific overrides. + +## Basic Usage + +### Storing Configuration + +```php +use Core\Config\ConfigService; + +$config = app(ConfigService::class); + +// Store simple value +$config->set('app.name', 'My Application'); + +// Store nested configuration +$config->set('mail.driver', 'smtp', [ + 'host' => 'smtp.mailtrap.io', + 'port' => 2525, + 'encryption' => 'tls', +]); + +// Store with profile +$config->set('cache.driver', 'redis', [], 'production'); +``` + +### Retrieving Configuration + +```php +// Get simple value +$name = $config->get('app.name'); + +// Get with default +$driver = $config->get('cache.driver', 'file'); + +// Get nested value +$host = $config->get('mail.driver.host'); + +// Get from specific profile +$driver = $config->get('cache.driver', 'file', 'production'); +``` + +## Profiles + +Profiles enable environment-specific configuration: + +### Creating Profiles + +```php +use Core\Config\Models\ConfigProfile; + +// Development profile +$dev = ConfigProfile::create([ + 'name' => 'development', + 'description' => 'Development environment settings', + 'is_active' => true, +]); + +// Staging profile +$staging = ConfigProfile::create([ + 'name' => 'staging', + 'description' => 'Staging environment', + 'is_active' => false, +]); + +// Production profile +$prod = ConfigProfile::create([ + 'name' => 'production', + 'description' => 'Production environment', + 'is_active' => false, +]); +``` + +### Activating Profiles + +```php +// Activate production profile +$prod->activate(); + +// Deactivate all others +ConfigProfile::query() + ->where('id', '!=', $prod->id) + ->update(['is_active' => false]); +``` + +### Profile Inheritance + +```php +// Set base value +$config->set('cache.ttl', 3600); + +// Override in production +$config->set('cache.ttl', 86400, [], 'production'); + +// Override in development +$config->set('cache.ttl', 60, [], 'development'); + +// Retrieval uses active profile automatically +$ttl = $config->get('cache.ttl'); // Returns profile-specific value +``` + +## Configuration Keys + +### Key Metadata + +```php +use Core\Config\Models\ConfigKey; + +$key = ConfigKey::create([ + 'key' => 'api.rate_limit', + 'description' => 'API rate limit per hour', + 'type' => 'integer', + 'is_sensitive' => false, + 'validation_rules' => ['required', 'integer', 'min:100'], +]); +``` + +### Sensitive Configuration + +```php +// Mark as sensitive (encrypted at rest) +$key = ConfigKey::create([ + 'key' => 'payment.stripe.secret', + 'is_sensitive' => true, +]); + +// Set sensitive value (auto-encrypted) +$config->set('payment.stripe.secret', 'sk_live_...'); + +// Retrieve (auto-decrypted) +$secret = $config->get('payment.stripe.secret'); +``` + +### Validation + +```php +// Validation runs automatically +try { + $config->set('api.rate_limit', 'invalid'); // Throws ValidationException +} catch (ValidationException $e) { + // Handle validation error +} + +// Valid value +$config->set('api.rate_limit', 1000); // ✅ Passes validation +``` + +## Versioning + +Track configuration changes with automatic versioning: + +### Creating Versions + +```php +use Core\Config\ConfigVersioning; + +$versioning = app(ConfigVersioning::class); + +// Create snapshot +$version = $versioning->createVersion('production', [ + 'description' => 'Pre-deployment snapshot', + 'created_by' => auth()->id(), +]); +``` + +### Viewing Versions + +```php +use Core\Config\Models\ConfigVersion; + +// List all versions +$versions = ConfigVersion::query() + ->where('profile', 'production') + ->orderByDesc('created_at') + ->get(); + +// Get specific version +$version = ConfigVersion::find($id); + +// View snapshot +$snapshot = $version->snapshot; // ['cache.driver' => 'redis', ...] +``` + +### Rolling Back + +```php +// Rollback to previous version +$versioning->rollback($version->id); + +// Rollback with confirmation +if ($version->created_at->isToday()) { + $versioning->rollback($version->id); +} +``` + +### Comparing Versions + +```php +use Core\Config\VersionDiff; + +$diff = app(VersionDiff::class); + +// Compare two versions +$changes = $diff->compare($oldVersion, $newVersion); + +// Output: +[ + 'added' => ['cache.prefix' => 'app_'], + 'modified' => ['cache.ttl' => ['old' => 3600, 'new' => 7200]], + 'removed' => ['cache.legacy_driver'], +] +``` + +## Import & Export + +### Exporting Configuration + +```php +use Core\Config\ConfigExporter; + +$exporter = app(ConfigExporter::class); + +// Export active profile +$json = $exporter->export(); + +// Export specific profile +$json = $exporter->export('production'); + +// Export with metadata +$json = $exporter->export('production', [ + 'include_sensitive' => false, // Exclude secrets + 'include_metadata' => true, // Include descriptions +]); +``` + +**Export Format:** + +```json +{ + "profile": "production", + "exported_at": "2026-01-26T12:00:00Z", + "config": { + "cache.driver": { + "value": "redis", + "description": "Cache driver", + "type": "string" + }, + "cache.ttl": { + "value": 86400, + "description": "Cache TTL in seconds", + "type": "integer" + } + } +} +``` + +### Importing Configuration + +```php +use Core\Config\ConfigService; + +$config = app(ConfigService::class); + +// Import from JSON +$result = $config->import($json, 'production'); + +// Import with merge strategy +$result = $config->import($json, 'production', [ + 'merge' => true, // Merge with existing + 'overwrite' => false, // Don't overwrite existing + 'validate' => true, // Validate before import +]); +``` + +**Import Result:** + +```php +use Core\Config\ImportResult; + +$result->imported; // ['cache.driver', 'cache.ttl'] +$result->skipped; // ['cache.legacy'] +$result->failed; // ['cache.invalid' => 'Validation failed'] +``` + +### Console Commands + +```bash +# Export configuration +php artisan config:export production --output=config.json + +# Import configuration +php artisan config:import config.json --profile=staging + +# Create version snapshot +php artisan config:version production --message="Pre-deployment" +``` + +## Configuration Providers + +Create reusable configuration providers: + +```php + [ + 'value' => 10, + 'description' => 'Posts per page', + 'type' => 'integer', + 'validation' => ['required', 'integer', 'min:1'], + ], + 'blog.allow_comments' => [ + 'value' => true, + 'description' => 'Enable comments', + 'type' => 'boolean', + ], + ]; + } +} +``` + +**Register Provider:** + +```php +use Core\Events\FrameworkBooted; + +public function onFrameworkBooted(FrameworkBooted $event): void +{ + $config = app(ConfigService::class); + $config->register(new BlogConfigProvider()); +} +``` + +## Caching + +Configuration is cached for performance: + +```php +// Clear config cache +$config->invalidate(); + +// Clear specific key cache +$config->invalidate('cache.driver'); + +// Rebuild cache +$config->rebuild(); +``` + +**Cache Strategy:** +- Uses `remember()` with 1-hour TTL +- Invalidated on config changes +- Per-profile cache keys +- Tagged for easy clearing + +## Events + +Configuration changes fire events: + +```php +use Core\Config\Events\ConfigChanged; +use Core\Config\Events\ConfigInvalidated; + +// Listen for changes +Event::listen(ConfigChanged::class, function ($event) { + Log::info('Config changed', [ + 'key' => $event->key, + 'old' => $event->oldValue, + 'new' => $event->newValue, + ]); +}); + +// Listen for cache invalidation +Event::listen(ConfigInvalidated::class, function ($event) { + // Rebuild dependent caches +}); +``` + +## Best Practices + +### 1. Use Profiles for Environments + +```php +// ✅ Good - environment-specific +$config->set('cache.driver', 'redis', [], 'production'); +$config->set('cache.driver', 'array', [], 'testing'); + +// ❌ Bad - single value for all environments +$config->set('cache.driver', 'redis'); +``` + +### 2. Mark Sensitive Data + +```php +// ✅ Good - encrypted at rest +ConfigKey::create([ + 'key' => 'payment.api_key', + 'is_sensitive' => true, +]); + +// ❌ Bad - plaintext secrets +$config->set('payment.api_key', 'secret123'); +``` + +### 3. Version Before Changes + +```php +// ✅ Good - create snapshot first +$versioning->createVersion('production', [ + 'description' => 'Pre-cache-driver-change', +]); +$config->set('cache.driver', 'redis', [], 'production'); + +// ❌ Bad - no rollback point +$config->set('cache.driver', 'redis', [], 'production'); +``` + +### 4. Validate Configuration + +```php +// ✅ Good - validation rules +ConfigKey::create([ + 'key' => 'api.rate_limit', + 'validation_rules' => ['required', 'integer', 'min:100', 'max:10000'], +]); + +// ❌ Bad - no validation +$config->set('api.rate_limit', 'unlimited'); // Invalid! +``` + +## Testing Configuration + +```php +use Tests\TestCase; +use Core\Config\ConfigService; + +class ConfigTest extends TestCase +{ + public function test_stores_configuration(): void + { + $config = app(ConfigService::class); + + $config->set('test.key', 'value'); + + $this->assertEquals('value', $config->get('test.key')); + } + + public function test_profile_isolation(): void + { + $config = app(ConfigService::class); + + $config->set('cache.driver', 'redis', [], 'production'); + $config->set('cache.driver', 'array', [], 'testing'); + + // Activate testing profile + ConfigProfile::where('name', 'testing')->first()->activate(); + + $this->assertEquals('array', $config->get('cache.driver')); + } +} +``` + +## Learn More + +- [Module System →](/core/modules) +- [Multi-Tenancy →](/core/tenancy) diff --git a/docs/build/php/events.md b/docs/build/php/events.md new file mode 100644 index 0000000..1cc6798 --- /dev/null +++ b/docs/build/php/events.md @@ -0,0 +1,420 @@ +# Lifecycle Events + +Core PHP Framework uses lifecycle events to coordinate module loading and system initialization. This event-driven architecture enables lazy loading and keeps modules decoupled. + +## Event Flow + +```mermaid +graph TD + A[Application Boot] --> B[WebRoutesRegistering] + A --> C[ApiRoutesRegistering] + A --> D[AdminPanelBooting] + A --> E[ClientRoutesRegistering] + A --> F[ConsoleBooting] + A --> G[McpToolsRegistering] + B --> H[FrameworkBooted] + C --> H + D --> H + E --> H + F --> H + G --> H +``` + +## Core Events + +### WebRoutesRegistering + +Fired when public web routes are being registered. + +```php + 'onWebRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('blog', __DIR__.'/Views'); + $event->translations('blog', __DIR__.'/Lang'); + + $event->routes(function () { + require __DIR__.'/Routes/web.php'; + }); + } +} +``` + +**Available Methods:** +- `views(string $namespace, string $path)` - Register view namespace +- `translations(string $namespace, string $path)` - Register translations +- `routes(Closure $callback)` - Register routes +- `middleware(array $middleware)` - Add global middleware + +### ApiRoutesRegistering + +Fired when API routes are being registered. + +```php +public function onApiRoutes(ApiRoutesRegistering $event): void +{ + $event->routes(function () { + Route::middleware(['auth:sanctum', 'scope:posts:read']) + ->get('/posts', [PostApiController::class, 'index']); + }); +} +``` + +**Available Methods:** +- `routes(Closure $callback)` - Register API routes +- `middleware(array $middleware)` - Add API middleware +- `prefix(string $prefix)` - Set route prefix +- `version(string $version)` - Set API version + +### AdminPanelBooting + +Fired when admin panel is initializing. + +```php +use Core\Events\AdminPanelBooting; +use Core\Front\Admin\Contracts\AdminMenuProvider; + +public function onAdminPanel(AdminPanelBooting $event): void +{ + $event->menu(new BlogMenuProvider()); + $event->views('blog-admin', __DIR__.'/Views/Admin'); + $event->livewire('blog', __DIR__.'/Livewire'); +} +``` + +**Available Methods:** +- `menu(AdminMenuProvider $provider)` - Register menu provider +- `views(string $namespace, string $path)` - Register admin views +- `livewire(string $namespace, string $path)` - Register Livewire components +- `assets(string $path)` - Register frontend assets + +### ClientRoutesRegistering + +Fired when authenticated client routes are being registered. + +```php +use Core\Events\ClientRoutesRegistering; + +public function onClientRoutes(ClientRoutesRegistering $event): void +{ + $event->routes(function () { + Route::middleware(['auth', 'verified']) + ->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index']); + }); + }); +} +``` + +### ConsoleBooting + +Fired when Artisan console is initializing. + +```php +use Core\Events\ConsoleBooting; + +public function onConsole(ConsoleBooting $event): void +{ + $event->commands([ + PublishPostsCommand::class, + GenerateSitemapCommand::class, + ]); + + $event->schedule(function ($schedule) { + $schedule->command('posts:publish') + ->hourly() + ->withoutOverlapping(); + }); +} +``` + +**Available Methods:** +- `commands(array $commands)` - Register Artisan commands +- `schedule(Closure $callback)` - Define scheduled tasks + +### McpToolsRegistering + +Fired when MCP (Model Context Protocol) tools are being registered. + +```php +use Core\Events\McpToolsRegistering; +use Mod\Blog\Mcp\BlogTools; + +public function onMcpTools(McpToolsRegistering $event): void +{ + $event->tool(new BlogTools()); +} +``` + +**Available Methods:** +- `tool(object $tool)` - Register MCP tool +- `resource(string $type, Closure $callback)` - Register resource provider +- `prompt(string $name, Closure $callback)` - Register prompt template + +### FrameworkBooted + +Fired after all modules have loaded. Use for late initialization. + +```php +use Core\Events\FrameworkBooted; + +public function onFrameworkBooted(FrameworkBooted $event): void +{ + // Run after all modules loaded + $this->registerPolicies(); + $this->publishAssets(); +} +``` + +## Custom Events + +Create custom lifecycle events by extending `LifecycleEvent`: + +```php +gateways[$name] = $class; + } + + public function getGateways(): array + { + return $this->gateways; + } + + public function version(): string + { + return '1.0.0'; + } +} +``` + +**Usage in Module:** + +```php +use Mod\Shop\Events\PaymentGatewaysRegistering; + +class Boot +{ + public static array $listens = [ + PaymentGatewaysRegistering::class => 'onPaymentGateways', + ]; + + public function onPaymentGateways(PaymentGatewaysRegistering $event): void + { + $event->gateway('stripe', StripeGateway::class); + $event->gateway('paypal', PayPalGateway::class); + } +} +``` + +## Event Versioning + +Events can declare versions for backward compatibility: + +```php +use Core\Events\Concerns\HasEventVersion; + +class MyEvent extends LifecycleEvent +{ + use HasEventVersion; + + public function version(): string + { + return '2.1.0'; + } +} +``` + +**Version Checking:** + +```php +if (version_compare($event->version(), '2.0.0', '>=')) { + // Use v2 features +} else { + // Fallback for v1 +} +``` + +## Lazy Loading + +Modules only instantiate when their events fire: + +```php +// ModuleRegistry registers lazy listeners +Event::listen(WebRoutesRegistering::class, function ($event) { + // Module instantiated only when event fires + $module = new \Mod\Blog\Boot(); + $module->onWebRoutes($event); +}); +``` + +**Benefits:** +- Faster boot times +- Lower memory usage +- Load only what's needed +- No unused module overhead + +## Event Profiling + +Profile listener execution in development: + +```php +use Core\Events\ListenerProfiler; + +// config/app.php +'providers' => [ + // ... + ListenerProfiler::class, // Only in development +], +``` + +**Output:** + +``` +Lifecycle Event Performance: +- WebRoutesRegistering: 45ms (12 listeners) +- ApiRoutesRegistering: 23ms (8 listeners) +- AdminPanelBooting: 67ms (15 listeners) +``` + +## Best Practices + +### 1. Keep Listeners Fast + +```php +// ✅ Good - quick registration +public function onWebRoutes(WebRoutesRegistering $event): void +{ + $event->routes(fn () => require __DIR__.'/Routes/web.php'); +} + +// ❌ Bad - heavy processing +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Don't do expensive operations here! + $this->generateSitemap(); + $this->warmCache(); +} +``` + +### 2. Use Appropriate Events + +```php +// ✅ Good - right event for the job +WebRoutesRegistering::class => 'onWebRoutes', +ConsoleBooting::class => 'onConsole', + +// ❌ Bad - wrong event +WebRoutesRegistering::class => 'registerCommands', // Use ConsoleBooting! +``` + +### 3. Defer Heavy Work + +```php +public function onFrameworkBooted(FrameworkBooted $event): void +{ + // ✅ Good - queue heavy work + dispatch(new BuildSearchIndex()); + + // ❌ Bad - blocking + $this->buildSearchIndex(); // Takes 5 seconds! +} +``` + +### 4. Handle Missing Dependencies + +```php +public function onAdminPanel(AdminPanelBooting $event): void +{ + if (!class_exists(Livewire::class)) { + Log::warning('Livewire not installed, skipping components'); + return; + } + + $event->livewire('blog', __DIR__.'/Livewire'); +} +``` + +## Testing Events + +```php +use Tests\TestCase; +use Core\Events\WebRoutesRegistering; + +class BlogBootTest extends TestCase +{ + public function test_registers_routes(): void + { + $event = new WebRoutesRegistering(); + + $boot = new \Mod\Blog\Boot(); + $boot->onWebRoutes($event); + + $this->assertTrue(Route::has('blog.index')); + } + + public function test_registers_views(): void + { + $event = new WebRoutesRegistering(); + $boot = new \Mod\Blog\Boot(); + $boot->onWebRoutes($event); + + $this->assertTrue( + View::getFinder()->getHints()['blog'] ?? false + ); + } +} +``` + +## Debugging Events + +Enable event logging: + +```php +// config/logging.php +'channels' => [ + 'lifecycle' => [ + 'driver' => 'single', + 'path' => storage_path('logs/lifecycle.log'), + 'level' => 'debug', + ], +], +``` + +**Log Output:** + +``` +[2026-01-26 12:00:00] Firing: WebRoutesRegistering +[2026-01-26 12:00:00] Listener: Mod\Blog\Boot@onWebRoutes (12ms) +[2026-01-26 12:00:00] Listener: Mod\Shop\Boot@onWebRoutes (8ms) +``` + +## Learn More + +- [Module System →](/core/modules) +- [Actions Pattern →](/core/actions) +- [Multi-Tenancy →](/core/tenancy) diff --git a/docs/build/php/getting-started.md b/docs/build/php/getting-started.md new file mode 100644 index 0000000..48b00ea --- /dev/null +++ b/docs/build/php/getting-started.md @@ -0,0 +1,150 @@ +# Getting Started + +Welcome to the Core PHP Framework! This guide will help you understand what the framework is, when to use it, and how to get started. + +## What is Core PHP? + +Core PHP is a **modular monolith framework** for Laravel that provides: + +- **Event-driven architecture** - Modules communicate via lifecycle events +- **Lazy loading** - Only load what you need when you need it +- **Multi-tenant isolation** - Built-in workspace scoping +- **Action patterns** - Testable, reusable business logic +- **Activity logging** - Audit trails out of the box + +## When to Use Core PHP + +### ✅ Good Fit + +- **Multi-tenant SaaS applications** - Built-in workspace isolation +- **Growing monoliths** - Need structure without microservices complexity +- **Modular applications** - Clear module boundaries with lazy loading +- **API-first applications** - Comprehensive API package with OpenAPI docs + +### ❌ Not a Good Fit + +- **Simple CRUD apps** - May be overkill for basic applications +- **Existing large codebases** - Migration would be significant effort +- **Need for polyglot services** - Better suited for monolithic PHP apps + +## Architecture Overview + +``` +┌─────────────────────────────────────────────┐ +│ Application Bootstrap │ +├─────────────────────────────────────────────┤ +│ LifecycleEventProvider │ +│ (fires WebRoutesRegistering, etc.) │ +└──────────────┬──────────────────────────────┘ + │ + ┌───────▼────────┐ + │ ModuleRegistry │ + │ (lazy loading) │ + └───────┬─────────┘ + │ + ┌───────▼────────────────┐ + │ Module Boot Classes │ + │ • Mod/Commerce/Boot.php │ + │ • Mod/Billing/Boot.php │ + │ • Mod/Analytics/Boot.php│ + └─────────────────────────┘ +``` + +Modules declare which events they're interested in: + +```php +class Boot +{ + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ]; +} +``` + +The framework only instantiates modules when their events fire. + +## Core Concepts + +### 1. Lifecycle Events + +Events fired during application bootstrap: + +- `WebRoutesRegistering` - Public web routes +- `AdminPanelBooting` - Admin panel +- `ApiRoutesRegistering` - REST API +- `ClientRoutesRegistering` - Authenticated client routes +- `ConsoleBooting` - Artisan commands +- `FrameworkBooted` - Late initialization + +### 2. Module System + +Modules are self-contained feature bundles: + +``` +app/Mod/Commerce/ +├── Boot.php # Module entry point +├── Actions/ # Business logic +├── Models/ # Eloquent models +├── Routes/ # Route files +├── Views/ # Blade templates +├── Migrations/ # Database migrations +└── config.php # Module configuration +``` + +### 3. Workspace Scoping + +All tenant data is automatically scoped: + +```php +use Core\Mod\Tenant\Concerns\BelongsToWorkspace; + +class Product extends Model +{ + use BelongsToWorkspace; +} + +// Automatically filtered to current workspace +$products = Product::all(); +``` + +### 4. Actions Pattern + +Single-purpose business logic: + +```php +use Core\Actions\Action; + +class CreateOrder +{ + use Action; + + public function handle(User $user, array $data): Order + { + // Business logic here + } +} + +// Usage +$order = CreateOrder::run($user, $validated); +``` + +## Next Steps + +- [Installation →](./installation) +- [Configuration →](./configuration) +- [Quick Start →](./quick-start) + +## Requirements + +- **PHP** 8.2 or higher +- **Laravel** 11 or 12 +- **Database** MySQL 8.0+, PostgreSQL 13+, or SQLite 3.35+ +- **Composer** 2.0+ + +## Support + +- 📖 [Documentation](https://docs.example.com) +- 💬 [GitHub Discussions](https://github.com/host-uk/core-php/discussions) +- 🐛 [Issue Tracker](https://github.com/host-uk/core-php/issues) +- 📧 [Email Support](mailto:support@host.uk.com) diff --git a/docs/build/php/index.md b/docs/build/php/index.md new file mode 100644 index 0000000..bb325d7 --- /dev/null +++ b/docs/build/php/index.md @@ -0,0 +1,273 @@ +# PHP Framework + +The PHP framework provides the foundation for Host UK applications including the module system, lifecycle events, multi-tenancy, and shared utilities. + +## Installation + +```bash +composer require host-uk/core +``` + +## Quick Start + +```php + 'onWebRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('example', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } +} +``` + +## Key Features + +### Foundation + +- **[Module System](/core/modules)** - Auto-discover and lazy-load modules based on lifecycle events +- **[Lifecycle Events](/core/events)** - Event-driven extension points throughout the framework +- **[Actions Pattern](/core/actions)** - Single-purpose business logic classes +- **[Service Discovery](/core/services)** - Automatic service registration and dependency management + +### Multi-Tenancy + +- **[Workspaces & Namespaces](/core/tenancy)** - Workspace and namespace scoping for data isolation +- **[Workspace Caching](/core/tenancy#workspace-caching)** - Isolated cache management per workspace +- **[Context Resolution](/core/tenancy#context-resolution)** - Automatic workspace/namespace detection + +### Data & Storage + +- **[Configuration Management](/core/configuration)** - Multi-profile configuration with versioning and export/import +- **[Activity Logging](/core/activity)** - Track changes to models with automatic workspace scoping +- **[Seeder Discovery](/core/seeders)** - Automatic seeder discovery with dependency ordering +- **[CDN Integration](/core/cdn)** - Unified CDN interface for BunnyCDN and Cloudflare + +### Content & Media + +- **[Media Processing](/core/media)** - Image optimization, responsive images, and thumbnails +- **[Search](/core/search)** - Unified search interface across modules with analytics +- **[SEO Tools](/core/seo)** - SEO metadata generation, sitemaps, and structured data + +### Security + +- **[Security Headers](/core/security)** - Configurable security headers with CSP support +- **[Email Shield](/core/email-shield)** - Disposable email detection and validation +- **[Action Gate](/core/action-gate)** - Permission-based action authorization +- **[Blocklist Service](/core/security#blocklist)** - IP blocklist and rate limiting + +### Utilities + +- **[Input Sanitization](/core/security#sanitization)** - XSS protection and input cleaning +- **[Encryption](/core/security#encryption)** - Additional encryption utilities (HadesEncrypt) +- **[Translation Memory](/core/i18n)** - Translation management with fuzzy matching and ICU support + +## Architecture + +The Core package follows a modular monolith architecture with: + +1. **Event-Driven Loading** - Modules are lazy-loaded based on lifecycle events +2. **Dependency Injection** - All services are resolved through Laravel's container +3. **Trait-Based Features** - Common functionality provided via traits (e.g., `LogsActivity`, `BelongsToWorkspace`) +4. **Multi-Tenancy First** - Workspace scoping is built into the foundation + +## Artisan Commands + +```bash +# Module Management +php artisan make:mod Blog +php artisan make:website Marketing +php artisan make:plug Stripe + +# Configuration +php artisan config:export production +php artisan config:import production.json +php artisan config:version + +# Maintenance +php artisan activity:prune --days=90 +php artisan email-shield:prune --days=30 +php artisan cache:warm + +# SEO +php artisan seo:generate-sitemap +php artisan seo:audit-canonical +php artisan seo:test-structured-data + +# Storage +php artisan storage:offload --disk=public +``` + +## Configuration + +```php +// config/core.php +return [ + 'module_paths' => [ + app_path('Core'), + app_path('Mod'), + app_path('Plug'), + ], + + 'modules' => [ + 'auto_discover' => true, + 'cache_enabled' => true, + ], + + 'seeders' => [ + 'auto_discover' => true, + 'paths' => [ + 'Mod/*/Database/Seeders', + 'Core/*/Database/Seeders', + ], + ], + + 'activity' => [ + 'enabled' => true, + 'retention_days' => 90, + 'log_ip_address' => false, + ], + + 'workspace_cache' => [ + 'enabled' => true, + 'ttl' => 3600, + 'use_tags' => true, + ], +]; +``` + +[View full configuration options →](/guide/configuration#core-configuration) + +## Events + +Core package dispatches these lifecycle events: + +- `Core\Events\WebRoutesRegistering` - Public web routes +- `Core\Events\AdminPanelBooting` - Admin panel initialization +- `Core\Events\ApiRoutesRegistering` - REST API routes +- `Core\Events\ClientRoutesRegistering` - Authenticated client routes +- `Core\Events\ConsoleBooting` - Artisan commands +- `Core\Events\McpToolsRegistering` - MCP tools +- `Core\Events\FrameworkBooted` - Late-stage initialization + +[Learn more about Lifecycle Events →](/core/events) + +## Middleware + +- `Core\Mod\Tenant\Middleware\RequireWorkspaceContext` - Ensure workspace is set +- `Core\Headers\SecurityHeaders` - Apply security headers +- `Core\Bouncer\BlocklistService` - IP blocklist +- `Core\Bouncer\Gate\ActionGateMiddleware` - Action authorization + +## Global Helpers + +```php +// Get current workspace +$workspace = workspace(); + +// Create activity log +activity() + ->performedOn($model) + ->log('action'); + +// Generate CDN URL +$url = cdn_url('path/to/asset.jpg'); + +// Get CSP nonce +$nonce = csp_nonce(); +``` + +## Best Practices + +### 1. Use Actions for Business Logic + +```php +// ✅ Good +$post = CreatePost::run($data); + +// ❌ Bad +$post = Post::create($data); +event(new PostCreated($post)); +Cache::forget('posts'); +``` + +### 2. Log Activity for Audit Trail + +```php +class Post extends Model +{ + use LogsActivity; + + protected array $activityLogAttributes = ['title', 'status', 'published_at']; +} +``` + +### 3. Use Workspace Scoping + +```php +class Post extends Model +{ + use BelongsToWorkspace; +} +``` + +### 4. Leverage Module System + +```php +// Create focused modules with clear boundaries +Mod/Blog/ +Mod/Commerce/ +Mod/Analytics/ +``` + +## Testing + +```php + 'Test Post', + 'content' => 'Test content', + ]); + + $this->assertDatabaseHas('posts', [ + 'title' => 'Test Post', + ]); + } +} +``` + +## Changelog + +See [CHANGELOG.md](https://github.com/host-uk/core-php/blob/main/packages/core-php/changelog/2026/jan/features.md) + +## License + +EUPL-1.2 + +## Learn More + +- [Module System →](/core/modules) +- [Lifecycle Events →](/core/events) +- [Multi-Tenancy →](/core/tenancy) +- [Configuration →](/core/configuration) +- [Activity Logging →](/core/activity) diff --git a/docs/build/php/installation.md b/docs/build/php/installation.md new file mode 100644 index 0000000..26f7c3b --- /dev/null +++ b/docs/build/php/installation.md @@ -0,0 +1,283 @@ +# Installation + +This guide covers installing the Core PHP Framework in a new or existing Laravel application. + +## Quick Start (Recommended) + +The fastest way to get started is using the `core:new` command from any existing Core PHP installation: + +```bash +php artisan core:new my-project +cd my-project +php artisan serve +``` + +This scaffolds a complete project with all Core packages pre-configured. + +### Command Options + +```bash +# Custom template +php artisan core:new my-api --template=host-uk/core-api-template + +# Specific version +php artisan core:new my-app --branch=v1.0.0 + +# Skip automatic installation +php artisan core:new my-app --no-install + +# Development mode (--prefer-source) +php artisan core:new my-app --dev + +# Overwrite existing directory +php artisan core:new my-app --force +``` + +## From GitHub Template + +You can also use the GitHub template directly: + +1. Visit [host-uk/core-template](https://github.com/host-uk/core-template) +2. Click "Use this template" +3. Clone your new repository +4. Run `composer install && php artisan core:install` + +## Manual Installation + +For adding Core PHP to an existing Laravel project: + +```bash +# Install Core PHP +composer require host-uk/core + +# Install optional packages +composer require host-uk/core-admin # Admin panel +composer require host-uk/core-api # REST API +composer require host-uk/core-mcp # MCP tools +``` + +## Existing Laravel Project + +Add to an existing Laravel 11+ or 12 application: + +```bash +composer require host-uk/core +``` + +The service provider will be auto-discovered. + +## Package Installation + +Install individual packages as needed: + +### Core Package (Required) + +```bash +composer require host-uk/core +``` + +Provides: +- Event-driven module system +- Actions pattern +- Multi-tenancy +- Activity logging +- Seeder auto-discovery + +### Admin Package (Optional) + +```bash +composer require host-uk/core-admin +``` + +Provides: +- Livewire admin panel +- Global search +- Service management UI +- Form components + +**Additional requirements:** +```bash +composer require livewire/livewire:"^3.0|^4.0" +composer require livewire/flux:"^2.0" +``` + +### API Package (Optional) + +```bash +composer require host-uk/core-api +``` + +Provides: +- OpenAPI/Swagger documentation +- Rate limiting +- Webhook signing +- Secure API keys + +### MCP Package (Optional) + +```bash +composer require host-uk/core-mcp +``` + +Provides: +- Model Context Protocol tools +- Tool analytics +- SQL query validation +- MCP playground UI + +## Publishing Configuration + +Publish configuration files: + +```bash +# Publish core config +php artisan vendor:publish --tag=core-config + +# Publish API config (if installed) +php artisan vendor:publish --tag=api-config + +# Publish MCP config (if installed) +php artisan vendor:publish --tag=mcp-config +``` + +## Database Setup + +Run migrations: + +```bash +php artisan migrate +``` + +This creates tables for: +- Workspaces and users +- API keys (if core-api installed) +- MCP analytics (if core-mcp installed) +- Activity logs (if spatie/laravel-activitylog installed) + +## Optional Dependencies + +### Activity Logging + +For activity logging features: + +```bash +composer require spatie/laravel-activitylog:"^4.8" +php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations" +php artisan migrate +``` + +### Feature Flags + +For feature flag support: + +```bash +composer require laravel/pennant:"^1.0" +php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider" +php artisan migrate +``` + +## Verify Installation + +Check that everything is installed correctly: + +```bash +# Check installed packages +composer show | grep host-uk + +# List available artisan commands +php artisan list make + +# Should see: +# make:mod Create a new module +# make:website Create a new website module +# make:plug Create a new plugin +``` + +## Environment Configuration + +Add to your `.env`: + +```env +# Core Configuration +CORE_MODULE_DISCOVERY=true +CORE_STRICT_WORKSPACE_MODE=true + +# API Configuration (if using core-api) +API_DOCS_ENABLED=true +API_DOCS_REQUIRE_AUTH=false +API_RATE_LIMIT_DEFAULT=60 + +# MCP Configuration (if using core-mcp) +MCP_ANALYTICS_ENABLED=true +MCP_QUOTA_ENABLED=true +MCP_DATABASE_CONNECTION=readonly +``` + +## Directory Structure + +After installation, your project structure will look like: + +``` +your-app/ +├── app/ +│ ├── Core/ # Core modules (framework-level) +│ ├── Mod/ # Feature modules (your code) +│ ├── Website/ # Website modules +│ └── Plug/ # Plugins +├── config/ +│ ├── core.php # Core configuration +│ ├── api.php # API configuration (optional) +│ └── mcp.php # MCP configuration (optional) +├── packages/ # Local package development (optional) +└── vendor/ + └── host-uk/ # Installed packages +``` + +## Next Steps + +- [Configuration →](./configuration) +- [Quick Start →](./quick-start) +- [Create Your First Module →](./quick-start#creating-a-module) + +## Troubleshooting + +### Service Provider Not Discovered + +If the service provider isn't auto-discovered: + +```bash +composer dump-autoload +php artisan package:discover --ansi +``` + +### Migration Errors + +If migrations fail: + +```bash +# Check database connection +php artisan db:show + +# Run migrations with verbose output +php artisan migrate --verbose +``` + +### Module Discovery Issues + +If modules aren't being discovered: + +```bash +# Clear application cache +php artisan optimize:clear + +# Verify module paths in config/core.php +php artisan config:show core.module_paths +``` + +## Minimum Requirements + +- PHP 8.2+ +- Laravel 11.0+ or 12.0+ +- MySQL 8.0+ / PostgreSQL 13+ / SQLite 3.35+ +- Composer 2.0+ +- 128MB PHP memory limit (256MB recommended) diff --git a/docs/build/php/media.md b/docs/build/php/media.md new file mode 100644 index 0000000..05b9afe --- /dev/null +++ b/docs/build/php/media.md @@ -0,0 +1,506 @@ +# Media Processing + +Powerful media processing with image optimization, responsive images, lazy thumbnails, and CDN integration. + +## Image Optimization + +### Automatic Optimization + +Images are automatically optimized on upload: + +```php +use Core\Media\Image\ImageOptimizer; + +$optimizer = app(ImageOptimizer::class); + +// Optimize image +$optimizer->optimize($path); + +// Returns optimized path with reduced file size +``` + +**Optimization Features:** +- Strip EXIF data (privacy) +- Lossless compression +- Format conversion (WebP/AVIF support) +- Quality adjustment +- Dimension constraints + +### Configuration + +```php +// config/media.php +return [ + 'optimization' => [ + 'enabled' => true, + 'quality' => 85, + 'max_width' => 2560, + 'max_height' => 2560, + 'strip_exif' => true, + 'convert_to_webp' => true, + ], +]; +``` + +### Manual Optimization + +```php +use Core\Media\Image\ImageOptimization; + +$optimization = app(ImageOptimization::class); + +// Optimize with custom quality +$optimization->optimize($path, quality: 90); + +// Optimize and resize +$optimization->optimize($path, maxWidth: 1920, maxHeight: 1080); + +// Get optimization stats +$stats = $optimization->getStats($path); +// ['original_size' => 2500000, 'optimized_size' => 890000, 'savings' => 64] +``` + +## Responsive Images + +### Generating Responsive Images + +```php +use Core\Media\Support\ImageResizer; + +$resizer = app(ImageResizer::class); + +// Generate multiple sizes +$sizes = $resizer->resize($originalPath, [ + 'thumbnail' => [150, 150], + 'small' => [320, 240], + 'medium' => [768, 576], + 'large' => [1920, 1440], +]); + +// Returns: +[ + 'thumbnail' => '/storage/images/photo-150x150.jpg', + 'small' => '/storage/images/photo-320x240.jpg', + 'medium' => '/storage/images/photo-768x576.jpg', + 'large' => '/storage/images/photo-1920x1440.jpg', +] +``` + +### Responsive Image Tag + +```blade + + + {{ $image->alt }} + +``` + +### Modern Format Support + +```php +use Core\Media\Image\ModernFormatSupport; + +$formats = app(ModernFormatSupport::class); + +// Check browser support +if ($formats->supportsWebP(request())) { + return cdn($image->webp); +} + +if ($formats->supportsAVIF(request())) { + return cdn($image->avif); +} + +return cdn($image->jpg); +``` + +**Blade Component:** + +```blade + +``` + +## Lazy Thumbnails + +Generate thumbnails on-demand: + +### Configuration + +```php +// config/media.php +return [ + 'lazy_thumbnails' => [ + 'enabled' => true, + 'cache_ttl' => 86400, // 24 hours + 'allowed_sizes' => [ + 'thumbnail' => [150, 150], + 'small' => [320, 240], + 'medium' => [768, 576], + 'large' => [1920, 1440], + ], + ], +]; +``` + +### Generating Thumbnails + +```php +use Core\Media\Thumbnail\LazyThumbnail; + +// Generate thumbnail URL (not created until requested) +$url = lazy_thumbnail($originalPath, 'medium'); +// Returns: /thumbnail/abc123/medium/photo.jpg + +// Generate with custom dimensions +$url = lazy_thumbnail($originalPath, [width: 500, height: 300]); +``` + +### Thumbnail Controller + +Thumbnails are generated on first request: + +``` +GET /thumbnail/{hash}/{size}/{filename} +``` + +**Process:** +1. Check if thumbnail exists in cache +2. If not, generate from original +3. Store in cache/CDN +4. Serve to client + +**Benefits:** +- No upfront processing +- Storage efficient +- CDN-friendly +- Automatic cleanup + +## Media Conversions + +Define custom media conversions: + +```php +resize($path, 400, 300) + ->optimize(quality: 85) + ->sharpen() + ->save(); + } +} +``` + +**Register Conversion:** + +```php +use Core\Events\FrameworkBooted; +use Core\Media\Conversions\MediaImageResizerConversion; + +public function onFrameworkBooted(FrameworkBooted $event): void +{ + MediaImageResizerConversion::register( + new PostThumbnailConversion() + ); +} +``` + +**Apply Conversion:** + +```php +use Core\Media\Jobs\ProcessMediaConversion; + +// Queue conversion +ProcessMediaConversion::dispatch($media, 'post-thumbnail'); + +// Synchronous conversion +$converted = $media->convert('post-thumbnail'); +``` + +## EXIF Data + +### Stripping EXIF + +Remove privacy-sensitive metadata: + +```php +use Core\Media\Image\ExifStripper; + +$stripper = app(ExifStripper::class); + +// Strip all EXIF data +$stripper->strip($imagePath); + +// Strip specific tags +$stripper->strip($imagePath, preserve: [ + 'orientation', // Keep orientation + 'copyright', // Keep copyright +]); +``` + +**Auto-strip on Upload:** + +```php +// config/media.php +return [ + 'optimization' => [ + 'strip_exif' => true, // Default: strip everything + 'preserve_exif' => ['orientation'], // Keep these tags + ], +]; +``` + +### Reading EXIF + +```php +use Intervention\Image\ImageManager; + +$manager = app(ImageManager::class); + +$image = $manager->read($path); +$exif = $image->exif(); + +$camera = $exif->get('Model'); // Camera model +$date = $exif->get('DateTimeOriginal'); // Photo date +$gps = $exif->get('GPSLatitude'); // GPS coordinates (privacy risk!) +``` + +## CDN Integration + +### Uploading to CDN + +```php +use Core\Cdn\Services\BunnyStorageService; + +$cdn = app(BunnyStorageService::class); + +// Upload file +$cdnPath = $cdn->upload($localPath, 'images/photo.jpg'); + +// Upload with public URL +$url = $cdn->uploadAndGetUrl($localPath, 'images/photo.jpg'); +``` + +### CDN Helper + +```blade +{{-- Blade template --}} +Photo + +{{-- With transformation --}} +Photo +``` + +### Purging CDN Cache + +```php +use Core\Cdn\Services\FluxCdnService; + +$cdn = app(FluxCdnService::class); + +// Purge single file +$cdn->purge('/images/photo.jpg'); + +// Purge multiple files +$cdn->purge([ + '/images/photo.jpg', + '/images/thumbnail.jpg', +]); + +// Purge entire directory +$cdn->purge('/images/*'); +``` + +## Progress Tracking + +Track conversion progress: + +```php +use Core\Media\Events\ConversionProgress; + +// Listen for progress +Event::listen(ConversionProgress::class, function ($event) { + echo "Processing: {$event->percentage}%\n"; + echo "Step: {$event->currentStep}/{$event->totalSteps}\n"; +}); +``` + +**With Livewire:** + +```php +class MediaUploader extends Component +{ + public $progress = 0; + + protected $listeners = ['conversionProgress' => 'updateProgress']; + + public function updateProgress($percentage) + { + $this->progress = $percentage; + } + + public function render() + { + return view('livewire.media-uploader'); + } +} +``` + +```blade +
+ @if($progress > 0) +
+
+
+

Processing: {{ $progress }}%

+ @endif +
+``` + +## Queued Processing + +Process media in background: + +```php +use Core\Media\Jobs\GenerateThumbnail; +use Core\Media\Jobs\ProcessMediaConversion; + +// Queue thumbnail generation +GenerateThumbnail::dispatch($media, 'large'); + +// Queue conversion +ProcessMediaConversion::dispatch($media, 'optimized'); + +// Chain jobs +GenerateThumbnail::dispatch($media, 'large') + ->chain([ + new ProcessMediaConversion($media, 'watermark'), + new ProcessMediaConversion($media, 'optimize'), + ]); +``` + +## Best Practices + +### 1. Optimize on Upload + +```php +// ✅ Good - optimize immediately +public function store(Request $request) +{ + $path = $request->file('image')->store('images'); + + $optimizer = app(ImageOptimizer::class); + $optimizer->optimize(storage_path("app/{$path}")); + + return $path; +} + +// ❌ Bad - serve unoptimized images +public function store(Request $request) +{ + return $request->file('image')->store('images'); +} +``` + +### 2. Use Lazy Thumbnails + +```php +// ✅ Good - generate on-demand + + +// ❌ Bad - generate all sizes upfront +$resizer->resize($path, [ + 'thumbnail' => [150, 150], + 'small' => [320, 240], + 'medium' => [768, 576], + 'large' => [1920, 1440], + 'xlarge' => [2560, 1920], +]); // Slow upload, wasted storage +``` + +### 3. Strip EXIF Data + +```php +// ✅ Good - protect privacy +$stripper->strip($imagePath); + +// ❌ Bad - leak GPS coordinates, camera info +// (no stripping) +``` + +### 4. Use CDN for Assets + +```php +// ✅ Good - CDN delivery + + +// ❌ Bad - serve from origin + +``` + +## Testing + +```php +use Tests\TestCase; +use Illuminate\Http\UploadedFile; +use Core\Media\Image\ImageOptimizer; + +class MediaTest extends TestCase +{ + public function test_optimizes_uploaded_image(): void + { + $file = UploadedFile::fake()->image('photo.jpg', 2000, 2000); + + $path = $file->store('test'); + $fullPath = storage_path("app/{$path}"); + + $originalSize = filesize($fullPath); + + $optimizer = app(ImageOptimizer::class); + $optimizer->optimize($fullPath); + + $optimizedSize = filesize($fullPath); + + $this->assertLessThan($originalSize, $optimizedSize); + } + + public function test_generates_lazy_thumbnail(): void + { + $path = UploadedFile::fake()->image('photo.jpg')->store('test'); + + $url = lazy_thumbnail($path, 'medium'); + + $this->assertStringContainsString('/thumbnail/', $url); + } +} +``` + +## Learn More + +- [CDN Integration →](/core/cdn) +- [Configuration →](/core/configuration) diff --git a/docs/build/php/modules.md b/docs/build/php/modules.md new file mode 100644 index 0000000..5c27c3d --- /dev/null +++ b/docs/build/php/modules.md @@ -0,0 +1,488 @@ +# Module System + +The module system provides automatic discovery and lazy loading of modules based on lifecycle events. Modules are self-contained units of functionality that can hook into the framework at specific points. + +## Overview + +Traditional Laravel applications use service providers which are all loaded on every request. The Core module system: + +- **Auto-discovers** modules by scanning directories +- **Lazy-loads** modules only when their events fire +- **Caches** module registry for performance +- **Supports** multiple module types (Mod, Plug, Website) + +## Creating a Module + +### Using Artisan + +```bash +# Create a standard module +php artisan make:mod Blog + +# Create a website module +php artisan make:website Marketing + +# Create a plugin module +php artisan make:plug Stripe +``` + +### Manual Creation + +Create a `Boot.php` file in your module directory: + +```php + 'onWebRoutes', + AdminPanelBooting::class => 'onAdminPanel', + ConsoleBooting::class => 'onConsole', + ]; + + /** + * Register public web routes + */ + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('blog', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + + /** + * Register admin panel routes and menus + */ + public function onAdminPanel(AdminPanelBooting $event): void + { + $event->menu('blog', [ + 'label' => 'Blog', + 'icon' => 'newspaper', + 'route' => 'admin.blog.index', + 'order' => 20, + ]); + + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } + + /** + * Register console commands + */ + public function onConsole(ConsoleBooting $event): void + { + $event->commands([ + Commands\PublishPostsCommand::class, + Commands\ImportPostsCommand::class, + ]); + } +} +``` + +## Directory Structure + +``` +Mod/Blog/ +├── Boot.php # Module bootstrap +├── Actions/ # Business logic +│ ├── CreatePost.php +│ ├── UpdatePost.php +│ └── DeletePost.php +├── Controllers/ +│ ├── Web/ +│ │ └── PostController.php +│ └── Admin/ +│ └── PostController.php +├── Models/ +│ ├── Post.php +│ └── Category.php +├── Routes/ +│ ├── web.php +│ ├── admin.php +│ └── api.php +├── Views/ +│ ├── web/ +│ └── admin/ +├── Database/ +│ ├── Migrations/ +│ ├── Factories/ +│ └── Seeders/ +├── Tests/ +│ ├── Feature/ +│ └── Unit/ +└── Lang/ + └── en_GB/ +``` + +## Lifecycle Events + +Modules can hook into these lifecycle events: + +### WebRoutesRegistering + +Register public-facing web routes: + +```php +public function onWebRoutes(WebRoutesRegistering $event): void +{ + // Register views + $event->views('blog', __DIR__.'/Views'); + + // Register translations + $event->lang('blog', __DIR__.'/Lang'); + + // Register routes + $event->routes(function () { + Route::get('/blog', [PostController::class, 'index']); + Route::get('/blog/{slug}', [PostController::class, 'show']); + }); +} +``` + +### AdminPanelBooting + +Register admin panel routes, menus, and widgets: + +```php +public function onAdminPanel(AdminPanelBooting $event): void +{ + // Register admin menu + $event->menu('blog', [ + 'label' => 'Blog', + 'icon' => 'newspaper', + 'route' => 'admin.blog.index', + 'order' => 20, + 'children' => [ + ['label' => 'Posts', 'route' => 'admin.blog.posts'], + ['label' => 'Categories', 'route' => 'admin.blog.categories'], + ], + ]); + + // Register routes + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); +} +``` + +### ApiRoutesRegistering + +Register REST API endpoints: + +```php +public function onApiRoutes(ApiRoutesRegistering $event): void +{ + $event->routes(function () { + Route::get('/posts', [Api\PostController::class, 'index']); + Route::post('/posts', [Api\PostController::class, 'store']); + Route::get('/posts/{id}', [Api\PostController::class, 'show']); + }); +} +``` + +### ClientRoutesRegistering + +Register authenticated client routes: + +```php +public function onClientRoutes(ClientRoutesRegistering $event): void +{ + $event->routes(function () { + Route::get('/dashboard/posts', [Client\PostController::class, 'index']); + Route::post('/dashboard/posts', [Client\PostController::class, 'store']); + }); +} +``` + +### ConsoleBooting + +Register Artisan commands: + +```php +public function onConsole(ConsoleBooting $event): void +{ + $event->commands([ + Commands\PublishPostsCommand::class, + Commands\GenerateSitemapCommand::class, + ]); + + $event->schedule(function (Schedule $schedule) { + $schedule->command('blog:publish-scheduled') + ->everyFiveMinutes(); + }); +} +``` + +### McpToolsRegistering + +Register MCP (Model Context Protocol) tools: + +```php +public function onMcpTools(McpToolsRegistering $event): void +{ + $event->tool('blog:create-post', Tools\CreatePostTool::class); + $event->tool('blog:list-posts', Tools\ListPostsTool::class); +} +``` + +### FrameworkBooted + +Late-stage initialization after all modules loaded: + +```php +public function onFrameworkBooted(FrameworkBooted $event): void +{ + // Register macros, observers, policies, etc. + Post::observe(PostObserver::class); + + Builder::macro('published', function () { + return $this->where('status', 'published') + ->where('published_at', '<=', now()); + }); +} +``` + +## Module Discovery + +The framework automatically scans these directories: + +```php +// config/core.php +'module_paths' => [ + app_path('Core'), // Core modules + app_path('Mod'), // Standard modules + app_path('Website'), // Website modules + app_path('Plug'), // Plugin modules +], +``` + +### Custom Namespaces + +Map custom paths to namespaces: + +```php +use Core\Module\ModuleScanner; + +$scanner = app(ModuleScanner::class); +$scanner->setNamespaceMap([ + '/Extensions' => 'Extensions\\', + '/Custom' => 'Custom\\Modules\\', +]); +``` + +## Lazy Loading + +Modules are only instantiated when their events fire: + +1. **Scan Phase** - `ModuleScanner` finds all `Boot.php` files +2. **Registry Phase** - `ModuleRegistry` wires lazy listeners +3. **Event Phase** - Event fires, `LazyModuleListener` instantiates module +4. **Execution Phase** - Module method is called + +**Performance Benefits:** +- Modules not used in CLI don't load in CLI +- Admin modules don't load on public requests +- API modules don't load on web requests + +## Module Registry + +View registered modules and their listeners: + +```php +use Core\Module\ModuleRegistry; + +$registry = app(ModuleRegistry::class); + +// Get all registered modules +$modules = $registry->all(); + +// Get modules for specific event +$webModules = $registry->forEvent(WebRoutesRegistering::class); +``` + +## Module Cache + +Module discovery is cached for performance: + +```bash +# Clear module cache +php artisan cache:clear + +# Or specifically +php artisan optimize:clear +``` + +**Cache Location:** `bootstrap/cache/modules.php` + +## Module Dependencies + +Modules can declare dependencies using service discovery: + +```php +use Core\Service\Contracts\ServiceDefinition; +use Core\Service\Contracts\ServiceDependency; + +class Boot implements ServiceDefinition +{ + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + ]; + + public function getServiceName(): string + { + return 'blog'; + } + + public function getServiceVersion(): string + { + return '1.0.0'; + } + + public function getDependencies(): array + { + return [ + new ServiceDependency('media', '>=1.0'), + new ServiceDependency('cdn', '>=2.0'), + ]; + } +} +``` + +## Testing Modules + +### Feature Tests + +```php + 'Test Post', + 'content' => 'Content here', + ]); + + $this->assertDatabaseHas('posts', [ + 'title' => 'Test Post', + ]); + + $this->get("/blog/{$post->slug}") + ->assertOk() + ->assertSee('Test Post'); + } +} +``` + +### Unit Tests + +```php +onWebRoutes($event); + + $this->assertTrue($event->hasRoutes()); + } +} +``` + +## Best Practices + +### 1. Keep Modules Focused + +```php +// ✅ Good - focused modules +Mod/Blog/ +Mod/Comments/ +Mod/Analytics/ + +// ❌ Bad - monolithic module +Mod/Everything/ +``` + +### 2. Use Proper Namespacing + +```php +// ✅ Good +namespace Mod\Blog\Controllers\Web; + +// ❌ Bad +namespace App\Http\Controllers; +``` + +### 3. Register Dependencies + +```php +// ✅ Good - declare dependencies +public function getDependencies(): array +{ + return [ + new ServiceDependency('media', '>=1.0'), + ]; +} +``` + +### 4. Only Hook Necessary Events + +```php +// ✅ Good - only web routes +public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', +]; + +// ❌ Bad - hooks everything +public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + AdminPanelBooting::class => 'onAdminPanel', + ApiRoutesRegistering::class => 'onApiRoutes', + // ... (when you don't need them all) +]; +``` + +### 5. Use Actions for Business Logic + +```php +// ✅ Good +$post = CreatePost::run($data); + +// ❌ Bad - logic in controller +public function store(Request $request) +{ + $post = Post::create($request->all()); + event(new PostCreated($post)); + Cache::forget('posts'); + return redirect()->route('posts.show', $post); +} +``` + +## Learn More + +- [Lifecycle Events →](/core/events) +- [Actions Pattern →](/core/actions) +- [Service Discovery →](/core/services) +- [Architecture Overview →](/architecture/module-system) diff --git a/docs/build/php/namespaces.md b/docs/build/php/namespaces.md new file mode 100644 index 0000000..435baef --- /dev/null +++ b/docs/build/php/namespaces.md @@ -0,0 +1,906 @@ +# Namespaces & Entitlements + +Core PHP Framework provides a sophisticated namespace and entitlements system for flexible multi-tenant SaaS applications. Namespaces provide universal tenant boundaries, while entitlements control feature access and usage limits. + +## Overview + +### The Problem + +Traditional multi-tenant systems force a choice: + +**Option A: User Ownership** +- Individual users own resources +- No team collaboration +- Billing per user + +**Option B: Workspace Ownership** +- Teams own resources via workspaces +- Can't have personal resources +- Billing per workspace + +Both approaches are too rigid for modern SaaS: +- **Agencies** need separate namespaces per client +- **Freelancers** want personal AND client resources +- **White-label operators** need brand isolation +- **Enterprise teams** need department-level isolation + +### The Solution: Namespaces + +Namespaces provide a **polymorphic ownership boundary** where resources belong to a namespace, and namespaces can be owned by either Users or Workspaces. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ User ────┬──→ Namespace (Personal) ──→ Resources │ +│ │ │ +│ └──→ Workspace ──→ Namespace (Client A) ──→ Res │ +│ └──→ Namespace (Client B) ──→ Res │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Benefits:** +- Users can have personal namespaces +- Workspaces can have multiple namespaces (one per client) +- Clean billing boundaries +- Complete resource isolation +- Flexible permission models + +## Namespace Model + +### Structure + +```php +Namespace { + id: int + uuid: string // Public identifier + name: string // Display name + slug: string // URL-safe identifier + description: ?string + icon: ?string + color: ?string + owner_type: string // User::class or Workspace::class + owner_id: int + workspace_id: ?int // Billing context (optional) + settings: ?json + is_default: bool // User's default namespace + is_active: bool + sort_order: int +} +``` + +### Ownership Patterns + +#### Personal Namespace (User-Owned) + +Individual user owns namespace for personal resources: + +```php +$namespace = Namespace_::create([ + 'name' => 'Personal', + 'owner_type' => User::class, + 'owner_id' => $user->id, + 'workspace_id' => $user->defaultHostWorkspace()->id, // For billing + 'is_default' => true, +]); +``` + +**Use Cases:** +- Personal projects +- Individual freelancer work +- Testing/development environments + +#### Agency Namespace (Workspace-Owned) + +Workspace owns namespace for client/project isolation: + +```php +$namespace = Namespace_::create([ + 'name' => 'Client: Acme Corp', + 'slug' => 'acme-corp', + 'owner_type' => Workspace::class, + 'owner_id' => $workspace->id, + 'workspace_id' => $workspace->id, // Same workspace for billing +]); +``` + +**Use Cases:** +- Agency client projects +- White-label deployments +- Department/team isolation + +#### White-Label Namespace + +SaaS operator creates namespaces for customers: + +```php +$namespace = Namespace_::create([ + 'name' => 'Customer Instance', + 'owner_type' => User::class, // Customer user owns it + 'owner_id' => $customerUser->id, + 'workspace_id' => $operatorWorkspace->id, // Operator billed +]); +``` + +**Use Cases:** +- White-label SaaS +- Reseller programs +- Managed services + +## Using Namespaces + +### Model Setup + +Add namespace scoping to models: + +```php +id(); + $table->foreignId('namespace_id') + ->constrained('namespaces') + ->cascadeOnDelete(); + $table->string('title'); + $table->text('content'); + $table->string('slug'); + $table->timestamps(); + + $table->index(['namespace_id', 'created_at']); +}); +``` + +### Automatic Scoping + +The `BelongsToNamespace` trait automatically handles scoping: + +```php +// Queries automatically scoped to current namespace +$pages = Page::ownedByCurrentNamespace()->get(); + +// Create automatically assigns namespace_id +$page = Page::create([ + 'title' => 'Example Page', + 'content' => 'Content...', + // namespace_id added automatically +]); + +// Can't access pages from other namespaces +$page = Page::find(999); // null if belongs to different namespace +``` + +### Namespace Context + +#### Middleware Resolution + +```php +// routes/web.php +Route::middleware(['auth', 'namespace']) + ->group(function () { + Route::get('/pages', [PageController::class, 'index']); + }); +``` + +The `ResolveNamespace` middleware sets current namespace from: +1. Query parameter: `?namespace=uuid` +2. Request header: `X-Namespace: uuid` +3. Session: `current_namespace_uuid` +4. User's default namespace + +#### Manual Context + +```php +use Core\Mod\Tenant\Services\NamespaceService; + +$namespaceService = app(NamespaceService::class); + +// Get current namespace +$current = $namespaceService->current(); + +// Set current namespace +$namespaceService->setCurrent($namespace); + +// Get all accessible namespaces +$namespaces = $namespaceService->accessibleByCurrentUser(); + +// Group by ownership +$grouped = $namespaceService->groupedForCurrentUser(); +// [ +// 'personal' => Collection, // User-owned +// 'workspaces' => [ // Workspace-owned +// ['workspace' => Workspace, 'namespaces' => Collection], +// ... +// ] +// ] +``` + +### Namespace Switcher UI + +Provide namespace switching in your UI: + +```blade +
+ + + {{ $currentNamespace->name }} + + + @foreach($personalNamespaces as $ns) + + {{ $ns->name }} + + @endforeach + + @foreach($workspaceNamespaces as $group) + {{ $group['workspace']->name }} + @foreach($group['namespaces'] as $ns) + + {{ $ns->name }} + + @endforeach + @endforeach + +
+``` + +### API Integration + +Include namespace in API requests: + +```bash +# Header-based +curl -H "X-Namespace: uuid-here" \ + -H "Authorization: Bearer sk_live_..." \ + https://api.example.com/v1/pages + +# Query parameter +curl "https://api.example.com/v1/pages?namespace=uuid-here" \ + -H "Authorization: Bearer sk_live_..." +``` + +## Entitlements System + +Entitlements control **what users can do** within their namespaces. The system answers: *"Can this namespace perform this action?"* + +### Core Concepts + +#### Packages + +Bundles of features with defined limits: + +```php +Package { + id: int + code: string // 'social-creator', 'bio-pro' + name: string + is_base_package: bool // Only one base package per namespace + is_stackable: bool // Can have multiple addon packages + is_active: bool + is_public: bool // Shown in pricing page +} +``` + +**Types:** +- **Base Package**: Core subscription (e.g., "Pro Plan") +- **Add-on Package**: Stackable extras (e.g., "Extra Storage") + +#### Features + +Capabilities or limits that can be granted: + +```php +Feature { + id: int + code: string // 'social.accounts', 'ai.credits' + name: string + type: enum // boolean, limit, unlimited + reset_type: enum // none, monthly, rolling + rolling_window_days: ?int + parent_feature_id: ?int // For hierarchical limits + category: string // 'social', 'ai', 'storage' +} +``` + +**Feature Types:** + +| Type | Behavior | Example | +|------|----------|---------| +| **Boolean** | On/off access gate | `tier.apollo`, `host.social` | +| **Limit** | Numeric cap on usage | `social.accounts: 5`, `ai.credits: 100` | +| **Unlimited** | No cap | `social.posts: unlimited` | + +**Reset Types:** + +| Reset Type | Behavior | Example | +|------------|----------|---------| +| **None** | Usage accumulates forever | Account limits | +| **Monthly** | Resets at billing cycle start | API requests per month | +| **Rolling** | Rolling window (e.g., last 30 days) | Posts per day | + +#### Hierarchical Features (Pools) + +Child features share a parent's limit pool: + +``` +host.storage.total (1000 MB) ← Parent pool +├── host.cdn ← Draws from parent +├── bio.cdn ← Draws from parent +└── social.cdn ← Draws from parent +``` + +**Configuration:** + +```php +Feature::create([ + 'code' => 'host.storage.total', + 'name' => 'Total Storage', + 'type' => 'limit', + 'reset_type' => 'none', +]); + +Feature::create([ + 'code' => 'bio.cdn', + 'name' => 'Bio Link Storage', + 'type' => 'limit', + 'parent_feature_id' => $parentFeature->id, // Shares pool +]); +``` + +### Entitlement Checks + +Use the entitlement service to check permissions: + +```php +use Core\Mod\Tenant\Services\EntitlementService; + +$entitlements = app(EntitlementService::class); + +// Check if namespace can use feature +$result = $entitlements->can($namespace, 'social.accounts', quantity: 3); + +if ($result->isDenied()) { + return back()->with('error', $result->getMessage()); +} + +// Proceed with action... + +// Record usage +$entitlements->recordUsage($namespace, 'social.accounts', quantity: 1); +``` + +### Entitlement Result + +The `EntitlementResult` object provides complete context: + +```php +$result = $entitlements->can($namespace, 'ai.credits', quantity: 10); + +// Status checks +$result->isAllowed(); // true/false +$result->isDenied(); // true/false +$result->isUnlimited(); // true if unlimited + +// Limits +$result->limit; // 100 +$result->used; // 75 +$result->remaining; // 25 + +// Percentage +$result->getUsagePercentage(); // 75.0 +$result->isNearLimit(); // true if > 80% + +// Denial reason +$result->getMessage(); // "Exceeded limit for ai.credits" +``` + +### Usage Tracking + +Record consumption after successful actions: + +```php +$entitlements->recordUsage( + namespace: $namespace, + featureCode: 'ai.credits', + quantity: 10, + user: $user, // Optional: who triggered it + metadata: [ // Optional: context + 'model' => 'claude-3', + 'tokens' => 1500, + ] +); +``` + +**Database Schema:** + +```php +usage_records { + id: int + namespace_id: int + feature_id: int + workspace_id: ?int // For workspace-level aggregation + user_id: ?int + quantity: int + metadata: ?json + created_at: timestamp +} +``` + +### Boosts + +Temporary or permanent additions to limits: + +```php +Boost { + id: int + namespace_id: int + feature_id: int + boost_type: enum // add_limit, enable, unlimited + duration_type: enum // cycle_bound, duration, permanent + limit_value: ?int // Amount to add + consumed_quantity: int // How much used + expires_at: ?timestamp + status: enum // active, exhausted, expired +} +``` + +**Use Cases:** +- One-time credit top-ups +- Promotional extras +- Beta access grants +- Temporary unlimited access + +**Example:** + +```php +// Give 1000 bonus AI credits +Boost::create([ + 'namespace_id' => $namespace->id, + 'feature_id' => $aiCreditsFeature->id, + 'boost_type' => 'add_limit', + 'duration_type' => 'cycle_bound', // Expires at billing cycle end + 'limit_value' => 1000, +]); +``` + +### Package Assignment + +Namespaces subscribe to packages: + +```php +NamespacePackage { + id: int + namespace_id: int + package_id: int + status: enum // active, suspended, cancelled, expired + starts_at: timestamp + expires_at: ?timestamp + billing_cycle_anchor: timestamp +} +``` + +**Provision Package:** + +```php +$entitlements->provisionPackage( + namespace: $namespace, + package: $package, + startsAt: now(), + expiresAt: now()->addMonth(), +); +``` + +**Package Features:** + +Features are attached to packages with specific limits: + +```php +// Package definition +$package = Package::find($packageId); + +// Attach features with limits +$package->features()->attach($feature->id, [ + 'limit_value' => 5, // This package grants 5 accounts +]); + +// Multiple features +$package->features()->sync([ + $socialAccountsFeature->id => ['limit_value' => 5], + $aiCreditsFeature->id => ['limit_value' => 100], + $storageFeature->id => ['limit_value' => 1000], // MB +]); +``` + +## Usage Dashboard + +Display usage stats to users: + +```php +$summary = $entitlements->getUsageSummary($namespace); + +// Returns array grouped by category: +[ + 'social' => [ + [ + 'feature' => Feature, + 'limit' => 5, + 'used' => 3, + 'remaining' => 2, + 'percentage' => 60.0, + 'is_unlimited' => false, + ], + ... + ], + 'ai' => [...], +] +``` + +**UI Example:** + +```blade +@foreach($summary as $category => $features) +
+

{{ ucfirst($category) }}

+ + @foreach($features as $item) +
+
+ {{ $item['feature']->name }} +
+ + @if($item['is_unlimited']) +
Unlimited
+@else +
+
+
+
+ +
+ {{ $item['used'] }} / {{ $item['limit'] }} + ({{ number_format($item['percentage'], 1) }}%) +
+ @endif +
+ @endforeach +
+@endforeach +``` + +## Billing Integration + +### Billing Context + +Namespaces use `workspace_id` for billing aggregation: + +```php +// Get billing workspace +$billingWorkspace = $namespace->getBillingContext(); + +// User-owned namespace → User's default workspace +// Workspace-owned namespace → Owner workspace +// Explicit workspace_id → That workspace +``` + +### Commerce Integration + +Link subscriptions to namespace packages: + +```php +// When subscription created +event(new SubscriptionCreated($subscription)); + +// Listener provisions package +$entitlements->provisionPackage( + namespace: $subscription->namespace, + package: $subscription->package, + startsAt: $subscription->starts_at, + expiresAt: $subscription->expires_at, +); + +// When subscription renewed +$namespacePackage->update([ + 'expires_at' => $subscription->next_billing_date, + 'billing_cycle_anchor' => now(), +]); + +// Expire cycle-bound boosts +Boost::where('namespace_id', $namespace->id) + ->where('duration_type', 'cycle_bound') + ->update(['status' => 'expired']); +``` + +### External Billing Systems + +API endpoints for external billing (Blesta, Stripe, etc.): + +```bash +# Provision package +POST /api/v1/entitlements +{ + "namespace_uuid": "uuid", + "package_code": "social-creator", + "starts_at": "2026-01-01T00:00:00Z", + "expires_at": "2026-02-01T00:00:00Z" +} + +# Suspend package +POST /api/v1/entitlements/{id}/suspend + +# Cancel package +POST /api/v1/entitlements/{id}/cancel + +# Renew package +POST /api/v1/entitlements/{id}/renew +{ + "expires_at": "2026-03-01T00:00:00Z" +} + +# Check entitlements +GET /api/v1/entitlements/check + ?namespace=uuid + &feature=social.accounts + &quantity=1 +``` + +## Audit Logging + +All entitlement changes are logged: + +```php +EntitlementLog { + id: int + namespace_id: int + workspace_id: ?int + action: enum // package_provisioned, boost_expired, etc. + source: enum // blesta, commerce, admin, system, api + user_id: ?int + data: json // Context about the change + created_at: timestamp +} +``` + +**Actions:** +- `package_provisioned`, `package_suspended`, `package_cancelled` +- `boost_provisioned`, `boost_exhausted`, `boost_expired` +- `usage_recorded`, `usage_denied` + +**Retrieve logs:** + +```php +$logs = EntitlementLog::where('namespace_id', $namespace->id) + ->latest() + ->paginate(20); +``` + +## Feature Seeder + +Define features in seeders: + +```php + 'tier.apollo', + 'name' => 'Apollo Tier', + 'type' => 'boolean', + 'category' => 'tier', + ]); + + // Social features + Feature::create([ + 'code' => 'social.accounts', + 'name' => 'Social Accounts', + 'type' => 'limit', + 'reset_type' => 'none', + 'category' => 'social', + ]); + + Feature::create([ + 'code' => 'social.posts.scheduled', + 'name' => 'Scheduled Posts', + 'type' => 'limit', + 'reset_type' => 'monthly', + 'category' => 'social', + ]); + + // AI features + Feature::create([ + 'code' => 'ai.credits', + 'name' => 'AI Credits', + 'type' => 'limit', + 'reset_type' => 'monthly', + 'category' => 'ai', + ]); + + // Storage pool + $storagePool = Feature::create([ + 'code' => 'host.storage.total', + 'name' => 'Total Storage', + 'type' => 'limit', + 'reset_type' => 'none', + 'category' => 'storage', + ]); + + // Child features share pool + Feature::create([ + 'code' => 'host.cdn', + 'name' => 'CDN Storage', + 'type' => 'limit', + 'parent_feature_id' => $storagePool->id, + 'category' => 'storage', + ]); + } +} +``` + +## Testing + +### Test Namespace Isolation + +```php +public function test_cannot_access_other_namespace_resources(): void +{ + $namespace1 = Namespace_::factory()->create(); + $namespace2 = Namespace_::factory()->create(); + + $page = Page::factory()->for($namespace1, 'namespace')->create(); + + // Set context to namespace2 + request()->attributes->set('current_namespace', $namespace2); + + // Should not find page from namespace1 + $this->assertNull(Page::ownedByCurrentNamespace()->find($page->id)); +} +``` + +### Test Entitlements + +```php +public function test_enforces_feature_limits(): void +{ + $namespace = Namespace_::factory()->create(); + + $package = Package::factory()->create(); + $feature = Feature::factory()->create([ + 'code' => 'social.accounts', + 'type' => 'limit', + ]); + + $package->features()->attach($feature->id, ['limit_value' => 5]); + + $entitlements = app(EntitlementService::class); + $entitlements->provisionPackage($namespace, $package); + + // Can create up to limit + for ($i = 0; $i < 5; $i++) { + $result = $entitlements->can($namespace, 'social.accounts'); + $this->assertTrue($result->isAllowed()); + $entitlements->recordUsage($namespace, 'social.accounts'); + } + + // 6th attempt denied + $result = $entitlements->can($namespace, 'social.accounts'); + $this->assertTrue($result->isDenied()); +} +``` + +## Best Practices + +### 1. Always Use Namespace Scoping + +```php +// ✅ Good - scoped to namespace +class Page extends Model +{ + use BelongsToNamespace; +} + +// ❌ Bad - no isolation +class Page extends Model { } +``` + +### 2. Check Entitlements Before Actions + +```php +// ✅ Good - check before creating +$result = $entitlements->can($namespace, 'social.accounts'); +if ($result->isDenied()) { + return back()->with('error', $result->getMessage()); +} + +SocialAccount::create($data); +$entitlements->recordUsage($namespace, 'social.accounts'); + +// ❌ Bad - no entitlement check +SocialAccount::create($data); +``` + +### 3. Use Descriptive Feature Codes + +```php +// ✅ Good - clear hierarchy +'social.accounts' +'social.posts.scheduled' +'ai.credits.claude' + +// ❌ Bad - unclear +'accounts' +'posts' +'credits' +``` + +### 4. Provide Usage Visibility + +Always show users their current usage and limits in the UI. + +### 5. Log Entitlement Changes + +All provisioning, suspension, and cancellation should be logged for audit purposes. + +## Migration from Workspace-Only + +If migrating from workspace-only system: + +```php +// Create namespace for each workspace +foreach (Workspace::all() as $workspace) { + $namespace = Namespace_::create([ + 'name' => $workspace->name, + 'owner_type' => Workspace::class, + 'owner_id' => $workspace->id, + 'workspace_id' => $workspace->id, + 'is_default' => true, + ]); + + // Migrate existing resources + Resource::where('workspace_id', $workspace->id) + ->update(['namespace_id' => $namespace->id]); + + // Migrate packages + WorkspacePackage::where('workspace_id', $workspace->id) + ->each(function ($wp) use ($namespace) { + NamespacePackage::create([ + 'namespace_id' => $namespace->id, + 'package_id' => $wp->package_id, + 'status' => $wp->status, + 'starts_at' => $wp->starts_at, + 'expires_at' => $wp->expires_at, + ]); + }); +} +``` + +## Learn More + +- [Multi-Tenancy Architecture →](/architecture/multi-tenancy) +- [Entitlements RFC](https://github.com/host-uk/core-php/blob/main/docs/rfc/RFC-004-ENTITLEMENTS.md) +- [API Package →](/packages/api) +- [Security Overview →](/security/overview) diff --git a/docs/build/php/patterns/actions.md b/docs/build/php/patterns/actions.md new file mode 100644 index 0000000..3808dda --- /dev/null +++ b/docs/build/php/patterns/actions.md @@ -0,0 +1,776 @@ +# Actions Pattern + +Actions are single-purpose classes that encapsulate business logic. They provide a clean, testable, and reusable way to handle complex operations. + +## Why Actions? + +### Traditional Controller (Fat Controllers) + +```php +class PostController extends Controller +{ + public function store(Request $request) + { + // Validation + $validated = $request->validate([/*...*/]); + + // Business logic mixed with controller concerns + $slug = Str::slug($validated['title']); + + if (Post::where('slug', $slug)->exists()) { + $slug .= '-' . Str::random(5); + } + + $post = Post::create([ + 'title' => $validated['title'], + 'slug' => $slug, + 'content' => $validated['content'], + 'workspace_id' => auth()->user()->workspace_id, + ]); + + if ($request->has('tags')) { + $post->tags()->sync($validated['tags']); + } + + event(new PostCreated($post)); + + Cache::tags(['posts'])->flush(); + + return redirect()->route('posts.show', $post); + } +} +``` + +**Problems:** +- Business logic tied to HTTP layer +- Hard to reuse from console, jobs, or tests +- Difficult to test in isolation +- Controller responsibilities bloat + +### Actions Pattern (Clean Separation) + +```php +class PostController extends Controller +{ + public function store(StorePostRequest $request) + { + $post = CreatePost::run($request->validated()); + + return redirect()->route('posts.show', $post); + } +} + +class CreatePost +{ + use Action; + + public function handle(array $data): Post + { + $slug = $this->generateUniqueSlug($data['title']); + + $post = Post::create([ + 'title' => $data['title'], + 'slug' => $slug, + 'content' => $data['content'], + ]); + + if (isset($data['tags'])) { + $post->tags()->sync($data['tags']); + } + + event(new PostCreated($post)); + Cache::tags(['posts'])->flush(); + + return $post; + } + + private function generateUniqueSlug(string $title): string + { + $slug = Str::slug($title); + + if (Post::where('slug', $slug)->exists()) { + $slug .= '-' . Str::random(5); + } + + return $slug; + } +} +``` + +**Benefits:** +- Business logic isolated from HTTP concerns +- Reusable from anywhere (controllers, jobs, commands, tests) +- Easy to test +- Single responsibility +- Dependency injection support + +## Creating Actions + +### Basic Action + +```php +update([ + 'published_at' => now(), + 'status' => 'published', + ]); + + return $post; + } +} +``` + +### Using Actions + +```php +// Static call (recommended) +$post = PublishPost::run($post); + +// Instance call +$action = new PublishPost(); +$post = $action->handle($post); + +// Via container (with DI) +$post = app(PublishPost::class)->handle($post); +``` + +## Dependency Injection + +Actions support constructor dependency injection: + +```php +posts->create($data); + + $this->events->dispatch(new PostCreated($post)); + $this->cache->tags(['posts'])->flush(); + + return $post; + } +} +``` + +## Action Return Types + +### Returning Models + +```php +class CreatePost +{ + use Action; + + public function handle(array $data): Post + { + return Post::create($data); + } +} + +$post = CreatePost::run($data); +``` + +### Returning Collections + +```php +class GetRecentPosts +{ + use Action; + + public function handle(int $limit = 10): Collection + { + return Post::published() + ->latest('published_at') + ->limit($limit) + ->get(); + } +} + +$posts = GetRecentPosts::run(5); +``` + +### Returning Boolean + +```php +class DeletePost +{ + use Action; + + public function handle(Post $post): bool + { + return $post->delete(); + } +} + +$deleted = DeletePost::run($post); +``` + +### Returning DTOs + +```php +class AnalyzePost +{ + use Action; + + public function handle(Post $post): PostAnalytics + { + return new PostAnalytics( + views: $post->views()->count(), + averageReadTime: $this->calculateReadTime($post), + engagement: $this->calculateEngagement($post), + ); + } +} + +$analytics = AnalyzePost::run($post); +echo $analytics->views; +``` + +## Complex Actions + +### Multi-Step Actions + +```php +class ImportPostsFromWordPress +{ + use Action; + + public function __construct( + private WordPressClient $client, + private CreatePost $createPost, + private AttachCategories $attachCategories, + private ImportMedia $importMedia, + ) {} + + public function handle(string $siteUrl, array $options = []): ImportResult + { + $posts = $this->client->fetchPosts($siteUrl); + $imported = []; + $errors = []; + + foreach ($posts as $wpPost) { + try { + DB::transaction(function () use ($wpPost, &$imported) { + // Create post + $post = $this->createPost->handle([ + 'title' => $wpPost['title'], + 'content' => $wpPost['content'], + 'published_at' => $wpPost['date'], + ]); + + // Import media + if ($wpPost['featured_image']) { + $this->importMedia->handle($post, $wpPost['featured_image']); + } + + // Attach categories + $this->attachCategories->handle($post, $wpPost['categories']); + + $imported[] = $post; + }); + } catch (\Exception $e) { + $errors[] = [ + 'post' => $wpPost['title'], + 'error' => $e->getMessage(), + ]; + } + } + + return new ImportResult( + imported: collect($imported), + errors: collect($errors), + ); + } +} +``` + +### Actions with Validation + +```php +class UpdatePost +{ + use Action; + + public function __construct( + private ValidatePostData $validator, + ) {} + + public function handle(Post $post, array $data): Post + { + // Validate before processing + $validated = $this->validator->handle($data); + + $post->update($validated); + + return $post->fresh(); + } +} + +class ValidatePostData +{ + use Action; + + public function handle(array $data): array + { + return validator($data, [ + 'title' => 'required|max:255', + 'content' => 'required', + 'published_at' => 'nullable|date', + ])->validate(); + } +} +``` + +## Action Patterns + +### Command Pattern + +Actions are essentially the Command pattern: + +```php +interface ActionInterface +{ + public function handle(...$params); +} + +// Each action is a command +class PublishPost implements ActionInterface { } +class UnpublishPost implements ActionInterface { } +class SchedulePost implements ActionInterface { } +``` + +### Pipeline Pattern + +Chain multiple actions: + +```php +class ProcessNewPost +{ + use Action; + + public function handle(array $data): Post + { + return Pipeline::send($data) + ->through([ + ValidatePostData::class, + SanitizeContent::class, + CreatePost::class, + GenerateExcerpt::class, + GenerateSocialImages::class, + NotifySubscribers::class, + ]) + ->thenReturn(); + } +} +``` + +### Strategy Pattern + +Different strategies as actions: + +```php +interface PublishStrategy +{ + public function publish(Post $post): void; +} + +class PublishImmediately implements PublishStrategy +{ + public function publish(Post $post): void + { + $post->update(['published_at' => now()]); + } +} + +class ScheduleForLater implements PublishStrategy +{ + public function publish(Post $post): void + { + PublishPostJob::dispatch($post) + ->delay($post->scheduled_at); + } +} + +class PublishPost +{ + use Action; + + public function handle(Post $post, PublishStrategy $strategy): void + { + $strategy->publish($post); + } +} +``` + +## Testing Actions + +### Unit Testing + +Test actions in isolation: + +```php + 'Test Post', + 'content' => 'Test content', + ]; + + $post = CreatePost::run($data); + + $this->assertInstanceOf(Post::class, $post); + $this->assertEquals('Test Post', $post->title); + $this->assertDatabaseHas('posts', [ + 'title' => 'Test Post', + ]); + } + + public function test_generates_unique_slug(): void + { + Post::factory()->create(['slug' => 'test-post']); + + $post = CreatePost::run([ + 'title' => 'Test Post', + 'content' => 'Content', + ]); + + $this->assertNotEquals('test-post', $post->slug); + $this->assertStringStartsWith('test-post-', $post->slug); + } +} +``` + +### Mocking Dependencies + +```php +public function test_dispatches_event_after_creation(): void +{ + Event::fake(); + + $post = CreatePost::run([ + 'title' => 'Test Post', + 'content' => 'Content', + ]); + + Event::assertDispatched(PostCreated::class, function ($event) use ($post) { + return $event->post->id === $post->id; + }); +} +``` + +### Integration Testing + +```php +public function test_import_creates_posts_from_wordpress(): void +{ + Http::fake([ + 'wordpress.example.com/*' => Http::response([ + [ + 'title' => 'WP Post 1', + 'content' => 'Content 1', + 'date' => '2026-01-01', + ], + [ + 'title' => 'WP Post 2', + 'content' => 'Content 2', + 'date' => '2026-01-02', + ], + ]), + ]); + + $result = ImportPostsFromWordPress::run('wordpress.example.com'); + + $this->assertCount(2, $result->imported); + $this->assertCount(0, $result->errors); + $this->assertEquals(2, Post::count()); +} +``` + +## Action Composition + +### Composing Actions + +Build complex operations from simple actions: + +```php +class PublishBlogPost +{ + use Action; + + public function __construct( + private UpdatePost $updatePost, + private GenerateOgImage $generateImage, + private NotifySubscribers $notifySubscribers, + private PingSearchEngines $pingSearchEngines, + ) {} + + public function handle(Post $post): Post + { + // Update post status + $post = $this->updatePost->handle($post, [ + 'status' => 'published', + 'published_at' => now(), + ]); + + // Generate social images + $this->generateImage->handle($post); + + // Notify subscribers + dispatch(fn () => $this->notifySubscribers->handle($post)) + ->afterResponse(); + + // Ping search engines + dispatch(fn () => $this->pingSearchEngines->handle($post)) + ->afterResponse(); + + return $post; + } +} +``` + +### Conditional Execution + +```php +class ProcessPost +{ + use Action; + + public function handle(Post $post, array $options = []): Post + { + if ($options['publish'] ?? false) { + PublishPost::run($post); + } + + if ($options['notify'] ?? false) { + NotifySubscribers::run($post); + } + + if ($options['generate_images'] ?? true) { + GenerateSocialImages::run($post); + } + + return $post; + } +} +``` + +## Best Practices + +### 1. Single Responsibility + +Each action should do one thing: + +```php +// ✅ Good - focused actions +class CreatePost { } +class PublishPost { } +class NotifySubscribers { } + +// ❌ Bad - does too much +class CreateAndPublishPostAndNotifySubscribers { } +``` + +### 2. Meaningful Names + +Use descriptive verb-noun names: + +```php +// ✅ Good names +class CreatePost { } +class UpdatePost { } +class DeletePost { } +class PublishPost { } +class UnpublishPost { } + +// ❌ Bad names +class PostAction { } +class HandlePost { } +class DoStuff { } +``` + +### 3. Return Values + +Always return something useful: + +```php +// ✅ Good - returns created model +public function handle(array $data): Post +{ + return Post::create($data); +} + +// ❌ Bad - returns nothing +public function handle(array $data): void +{ + Post::create($data); +} +``` + +### 4. Idempotency + +Make actions idempotent when possible: + +```php +class PublishPost +{ + use Action; + + public function handle(Post $post): Post + { + // Idempotent - safe to call multiple times + if ($post->isPublished()) { + return $post; + } + + $post->update(['published_at' => now()]); + + return $post; + } +} +``` + +### 5. Type Hints + +Always use type hints: + +```php +// ✅ Good - clear types +public function handle(Post $post, array $data): Post + +// ❌ Bad - no types +public function handle($post, $data) +``` + +## Common Use Cases + +### CRUD Operations + +```php +class CreatePost { } +class UpdatePost { } +class DeletePost { } +class RestorePost { } +``` + +### State Transitions + +```php +class PublishPost { } +class UnpublishPost { } +class ArchivePost { } +class SchedulePost { } +``` + +### Data Processing + +```php +class ImportPosts { } +class ExportPosts { } +class SyncPosts { } +class MigratePosts { } +``` + +### Calculations + +```php +class CalculatePostStatistics { } +class GeneratePostSummary { } +class AnalyzePostPerformance { } +``` + +### External Integrations + +```php +class SyncToWordPress { } +class PublishToMedium { } +class ShareOnSocial { } +``` + +## Action vs Service + +### When to Use Actions + +- Single, focused operations +- No state management needed +- Reusable across contexts + +### When to Use Services + +- Multiple related operations +- Stateful operations +- Facade for complex subsystem + +```php +// Action - single operation +class CreatePost +{ + use Action; + + public function handle(array $data): Post + { + return Post::create($data); + } +} + +// Service - multiple operations, state +class BlogService +{ + private Collection $posts; + + public function getRecentPosts(int $limit): Collection + { + return $this->posts ??= Post::latest()->limit($limit)->get(); + } + + public function getPopularPosts(int $limit): Collection { } + public function searchPosts(string $query): Collection { } + public function getPostsByCategory(Category $category): Collection { } +} +``` + +## Learn More + +- [Service Layer](/patterns-guide/services) +- [Repository Pattern](/patterns-guide/repositories) +- [Testing Actions](/testing/actions) diff --git a/docs/build/php/patterns/activity-logging.md b/docs/build/php/patterns/activity-logging.md new file mode 100644 index 0000000..742ac9c --- /dev/null +++ b/docs/build/php/patterns/activity-logging.md @@ -0,0 +1,678 @@ +# Activity Logging + +Core PHP Framework provides comprehensive activity logging to track changes to your models and user actions. Built on Spatie's `laravel-activitylog`, it adds workspace-scoped logging and automatic cleanup. + +## Overview + +Activity logging helps you: + +- Track who changed what and when +- Maintain audit trails for compliance +- Debug issues by reviewing historical changes +- Display activity feeds to users +- Revert changes when needed + +## Setup + +### Installation + +The activity log package is included in Core PHP: + +```bash +composer require spatie/laravel-activitylog +``` + +### Migration + +Run migrations to create the `activity_log` table: + +```bash +php artisan migrate +``` + +### Configuration + +Publish and customize the configuration: + +```bash +php artisan vendor:publish --tag=activitylog +``` + +Core PHP extends the default configuration: + +```php +// config/core.php +'activity' => [ + 'enabled' => env('ACTIVITY_LOG_ENABLED', true), + 'retention_days' => env('ACTIVITY_RETENTION_DAYS', 90), + 'cleanup_enabled' => true, + 'log_ip_address' => false, // GDPR compliance +], +``` + +## Basic Usage + +### Adding Logging to Models + +Use the `LogsActivity` trait: + +```php + 'My First Post', + 'content' => 'Hello world!', +]); +// Activity logged: "created" event + +$post->update(['title' => 'Updated Title']); +// Activity logged: "updated" event with changes + +$post->delete(); +// Activity logged: "deleted" event +``` + +### Manual Logging + +Log custom activities: + +```php +activity() + ->performedOn($post) + ->causedBy(auth()->user()) + ->withProperties(['custom' => 'data']) + ->log('published'); + +// Or use the helper on the model +$post->logActivity('published', ['published_at' => now()]); +``` + +## Configuration Options + +### Log Attributes + +Specify which attributes to track: + +```php +class Post extends Model +{ + use LogsActivity; + + // Log specific attributes + protected array $activityLogAttributes = ['title', 'content', 'status']; + + // Log all fillable attributes + protected static $logFillable = true; + + // Log all attributes + protected static $logAttributes = ['*']; + + // Log only dirty (changed) attributes + protected static $logOnlyDirty = true; + + // Don't log these attributes + protected static $logAttributesToIgnore = ['updated_at', 'view_count']; +} +``` + +### Log Events + +Control which events trigger logging: + +```php +class Post extends Model +{ + use LogsActivity; + + // Log only these events (default: all) + protected static $recordEvents = ['created', 'updated', 'deleted']; + + // Don't log these events + protected static $ignoreEvents = ['retrieved']; +} +``` + +### Custom Log Names + +Organize activities by type: + +```php +class Post extends Model +{ + use LogsActivity; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['title', 'content']) + ->logOnlyDirty() + ->setDescriptionForEvent(fn(string $eventName) => "Post {$eventName}") + ->useLogName('blog'); + } +} +``` + +## Retrieving Activity + +### Get All Activity + +```php +// All activity in the system +$activities = Activity::all(); + +// Recent activity +$recent = Activity::latest()->limit(10)->get(); + +// Activity for specific model +$postActivity = Activity::forSubject($post)->get(); + +// Activity by specific user +$userActivity = Activity::causedBy($user)->get(); +``` + +### Filtering Activity + +```php +// By log name +$blogActivity = Activity::inLog('blog')->get(); + +// By description +$publishedPosts = Activity::where('description', 'published')->get(); + +// By date range +$recentActivity = Activity::whereBetween('created_at', [ + now()->subDays(7), + now(), +])->get(); + +// By properties +$activity = Activity::whereJsonContains('properties->status', 'published')->get(); +``` + +### Activity Scopes + +Core PHP adds workspace scoping: + +```php +use Core\Activity\Scopes\ActivityScopes; + +// Activity for current workspace +$workspaceActivity = Activity::forCurrentWorkspace()->get(); + +// Activity for specific workspace +$activity = Activity::forWorkspace($workspace)->get(); + +// Activity for specific subject type +$postActivity = Activity::forSubjectType(Post::class)->get(); +``` + +## Activity Properties + +### Storing Extra Data + +```php +activity() + ->performedOn($post) + ->withProperties([ + 'old_status' => 'draft', + 'new_status' => 'published', + 'scheduled_at' => $post->published_at, + 'notified_subscribers' => true, + ]) + ->log('published'); +``` + +### Retrieving Properties + +```php +$activity = Activity::latest()->first(); + +$properties = $activity->properties; +$oldStatus = $activity->properties['old_status'] ?? null; + +// Access as object +$newStatus = $activity->properties->new_status; +``` + +### Changes Tracking + +View before/after values: + +```php +$post->update(['title' => 'New Title']); + +$activity = Activity::forSubject($post)->latest()->first(); + +$changes = $activity->changes(); +// [ +// 'attributes' => ['title' => 'New Title'], +// 'old' => ['title' => 'Old Title'] +// ] +``` + +## Activity Presentation + +### Display Activity Feed + +```php +// Controller +public function activityFeed() +{ + $activities = Activity::with(['causer', 'subject']) + ->forCurrentWorkspace() + ->latest() + ->paginate(20); + + return view('activity-feed', compact('activities')); +} +``` + +```blade + +@foreach($activities as $activity) +
+
+ @if($activity->description === 'created') + + + @elseif($activity->description === 'deleted') + × + @else + + @endif +
+ +
+

+ {{ $activity->causer->name ?? 'System' }} + {{ $activity->description }} + {{ class_basename($activity->subject_type) }} + @if($activity->subject) + + {{ $activity->subject->title }} + + @endif +

+ +
+
+@endforeach +``` + +### Custom Descriptions + +Make descriptions more readable: + +```php +class Post extends Model +{ + use LogsActivity; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->setDescriptionForEvent(function(string $eventName) { + return match($eventName) { + 'created' => 'created post "' . $this->title . '"', + 'updated' => 'updated post "' . $this->title . '"', + 'deleted' => 'deleted post "' . $this->title . '"', + 'published' => 'published post "' . $this->title . '"', + default => $eventName . ' post', + }; + }); + } +} +``` + +## Workspace Isolation + +### Automatic Scoping + +Activity is automatically scoped to workspaces: + +```php +// Only returns activity for current workspace +$activity = Activity::forCurrentWorkspace()->get(); + +// Explicitly query another workspace (admin only) +if (auth()->user()->isSuperAdmin()) { + $activity = Activity::forWorkspace($otherWorkspace)->get(); +} +``` + +### Cross-Workspace Activity + +```php +// Admin reports across all workspaces +$systemActivity = Activity::withoutGlobalScopes()->get(); + +// Activity counts by workspace +$stats = Activity::withoutGlobalScopes() + ->select('workspace_id', DB::raw('count(*) as count')) + ->groupBy('workspace_id') + ->get(); +``` + +## Activity Cleanup + +### Automatic Pruning + +Configure automatic cleanup of old activity: + +```php +// config/core.php +'activity' => [ + 'retention_days' => 90, + 'cleanup_enabled' => true, +], +``` + +Schedule the cleanup command: + +```php +// app/Console/Kernel.php +protected function schedule(Schedule $schedule) +{ + $schedule->command('activity:prune') + ->daily() + ->at('02:00'); +} +``` + +### Manual Pruning + +```bash +# Delete activity older than configured retention period +php artisan activity:prune + +# Delete activity older than specific number of days +php artisan activity:prune --days=30 + +# Dry run (see what would be deleted) +php artisan activity:prune --dry-run +``` + +### Selective Deletion + +```php +// Delete activity for specific model +Activity::forSubject($post)->delete(); + +// Delete activity by log name +Activity::inLog('temporary')->delete(); + +// Delete activity older than date +Activity::where('created_at', '<', now()->subMonths(6))->delete(); +``` + +## Advanced Usage + +### Batch Logging + +Log multiple changes as a single activity: + +```php +activity()->enableLogging(); + +// Disable automatic logging temporarily +activity()->disableLogging(); + +Post::create([/*...*/]); // Not logged +Post::create([/*...*/]); // Not logged +Post::create([/*...*/]); // Not logged + +// Re-enable and log batch operation +activity()->enableLogging(); + +activity() + ->performedOn($workspace) + ->log('imported 100 posts'); +``` + +### Custom Activity Models + +Extend the activity model: + +```php +where('properties->public', true); + } + + public function wasSuccessful(): bool + { + return $this->properties['success'] ?? true; + } +} +``` + +Update config: + +```php +// config/activitylog.php +'activity_model' => App\Models\Activity::class, +``` + +### Queued Logging + +Log activity in the background for performance: + +```php +// In a job or listener +dispatch(function () use ($post, $user) { + activity() + ->performedOn($post) + ->causedBy($user) + ->log('processed'); +})->afterResponse(); +``` + +## GDPR Compliance + +### Anonymize User Data + +Don't log personally identifiable information: + +```php +// config/core.php +'activity' => [ + 'log_ip_address' => false, + 'anonymize_after_days' => 30, +], +``` + +### Anonymization + +```php +class AnonymizeOldActivity +{ + public function handle(): void + { + Activity::where('created_at', '<', now()->subDays(30)) + ->whereNotNull('causer_id') + ->update([ + 'causer_id' => null, + 'causer_type' => null, + 'properties->ip_address' => null, + ]); + } +} +``` + +### User Data Deletion + +Delete user's activity when account is deleted: + +```php +class User extends Model +{ + protected static function booted() + { + static::deleting(function ($user) { + // Delete or anonymize activity + Activity::causedBy($user)->delete(); + }); + } +} +``` + +## Performance Optimization + +### Eager Loading + +Prevent N+1 queries: + +```php +$activities = Activity::with(['causer', 'subject']) + ->latest() + ->paginate(20); +``` + +### Selective Logging + +Only log important changes: + +```php +class Post extends Model +{ + use LogsActivity; + + // Only log changes to these critical fields + protected array $activityLogAttributes = ['title', 'published_at', 'status']; + + // Only log when attributes actually change + protected static $logOnlyDirty = true; +} +``` + +### Disable Logging Temporarily + +```php +// Disable for bulk operations +activity()->disableLogging(); + +Post::query()->update(['migrated' => true]); + +activity()->enableLogging(); +``` + +## Testing + +### Testing Activity Logging + +```php + 'Test Post', + 'content' => 'Test content', + ]); + + $activity = Activity::forSubject($post)->first(); + + $this->assertEquals('created', $activity->description); + $this->assertEquals(auth()->id(), $activity->causer_id); + } + + public function test_logs_attribute_changes(): void + { + $post = Post::factory()->create(['title' => 'Original']); + + $post->update(['title' => 'Updated']); + + $activity = Activity::forSubject($post)->latest()->first(); + + $this->assertEquals('updated', $activity->description); + $this->assertEquals('Original', $activity->changes()['old']['title']); + $this->assertEquals('Updated', $activity->changes()['attributes']['title']); + } +} +``` + +## Best Practices + +### 1. Log Business Events + +```php +// ✅ Good - meaningful business events +$post->logActivity('published', ['published_at' => now()]); +$post->logActivity('featured', ['featured_until' => $date]); + +// ❌ Bad - technical implementation details +$post->logActivity('database_updated'); +``` + +### 2. Include Context + +```php +// ✅ Good - rich context +activity() + ->performedOn($post) + ->withProperties([ + 'published_at' => $post->published_at, + 'notification_sent' => true, + 'subscribers_count' => $subscribersCount, + ]) + ->log('published'); + +// ❌ Bad - minimal context +activity()->performedOn($post)->log('published'); +``` + +### 3. Use Descriptive Log Names + +```php +// ✅ Good - organized by domain +activity()->useLog('blog')->log('post published'); +activity()->useLog('commerce')->log('order placed'); + +// ❌ Bad - generic log name +activity()->useLog('default')->log('thing happened'); +``` + +## Learn More + +- [Activity Feed UI](/packages/admin#activity-feed) +- [GDPR Compliance](/security/gdpr) +- [Testing Activity](/testing/activity-logging) diff --git a/docs/build/php/patterns/hlcrf.md b/docs/build/php/patterns/hlcrf.md new file mode 100644 index 0000000..64725b2 --- /dev/null +++ b/docs/build/php/patterns/hlcrf.md @@ -0,0 +1,872 @@ +# HLCRF Layout System + +HLCRF (Header-Left-Content-Right-Footer) is a hierarchical, composable layout system for building complex layouts with infinite nesting. It provides flexible region-based layouts without restricting HTML structure. + +## Overview + +Traditional Blade layouts force rigid inheritance hierarchies. HLCRF allows components to declare which layout regions they contribute to, enabling composition without structural constraints. + +**Use Cases:** +- Admin panels and dashboards +- Content management interfaces +- Marketing landing pages +- E-commerce product pages +- Documentation sites +- Any complex multi-region layout + +### Traditional Blade Layouts + +```blade +{{-- layouts/admin.blade.php --}} + + +
@yield('header')
+ +
@yield('content')
+ + + +{{-- pages/dashboard.blade.php --}} +@extends('layouts.admin') + +@section('header') + Dashboard Header +@endsection + +@section('content') + Dashboard Content +@endsection +``` + +**Problems:** +- Rigid structure +- Deep nesting +- Hard to compose sections +- Components can't contribute to multiple regions + +### HLCRF Approach + +```blade +{{-- pages/dashboard.blade.php --}} + + + Dashboard Header + + + + Navigation Menu + + + + Dashboard Content + + + + Sidebar Widgets + + +``` + +**Benefits:** +- Declarative region definition +- Easy composition +- Components contribute to any region +- No structural constraints + +## Layout Regions + +HLCRF defines five semantic regions: + +``` +┌────────────────────────────────────┐ +│ Header (H) │ +├──────┬─────────────────┬───────────┤ +│ │ │ │ +│ Left │ Content (C) │ Right │ +│ (L) │ │ (R) │ +│ │ │ │ +├──────┴─────────────────┴───────────┤ +│ Footer (F) │ +└────────────────────────────────────┘ +``` + +### Self-Documenting IDs + +Every HLCRF element receives a unique ID that describes its position in the DOM tree. This makes debugging, styling, and testing trivial: + +**ID Format:** `{Region}-{Index}-{NestedRegion}-{NestedIndex}...` + +**Examples:** +- `H-0` = First header element +- `L-1` = Second left sidebar element (0-indexed) +- `C-R-2` = Content region → Right sidebar → Third element +- `C-L-0-R-1` = Content → Left → First element → Right → Second element + +**Region Letters:** +- `H` = Header +- `L` = Left +- `C` = Content +- `R` = Right +- `F` = Footer + +**Benefits:** +1. **Instant debugging** - See element position from DevTools +2. **Precise CSS targeting** - No class soup needed +3. **Test selectors** - Stable IDs for E2E tests +4. **Documentation** - DOM structure is self-explanatory + +```html + +
+ +
+ +
+
+ + +
+ +
+ +
Main content
+
+ +
+ + +
+
+``` + +**CSS Examples:** + +```css +/* Target specific nested elements */ +#C-R-2 { width: 300px; } + +/* Target all right sidebars at any depth */ +[id$="-R-0"] { background: #f9f9f9; } + +/* Target deeply nested content regions */ +[id*="-C-"][id*="-C-"] { padding: 2rem; } + +/* Target second header element anywhere */ +[id^="H-1"], [id*="-H-1"] { font-weight: bold; } +``` + +### Header Region + +Top section for navigation, branding, global actions: + +```blade + + + +``` + +### Left Region + +Sidebar navigation, filters, secondary navigation: + +```blade + + + +``` + +### Content Region + +Main content area: + +```blade + +
+

Dashboard

+ +
+ + + +
+ +
+ +
+
+
+``` + +### Right Region + +Contextual help, related actions, widgets: + +```blade + + + +``` + +### Footer Region + +Copyright, links, status information: + +```blade + +
+ © 2026 Your Company. All rights reserved. + | + Privacy + | + Terms +
+
+``` + +## Component Composition + +### Multiple Components Contributing + +Components can contribute to multiple regions: + +```blade + + {{-- Page header --}} + + + + + {{-- Filters sidebar --}} + + + + + {{-- Main content --}} + + + + + {{-- Help sidebar --}} + + + + + +``` + +### Nested Layouts + +HLCRF layouts can be nested infinitely. Each element receives a unique, self-documenting ID that describes its position in the DOM tree: + +```blade +{{-- components/post-editor.blade.php --}} +
+ {{-- Nested HLCRF layout inside a parent layout --}} + + {{-- Editor toolbar goes to header --}} + + + + + {{-- Content editor --}} + + + + + {{-- Metadata sidebar --}} + + + + +
+``` + +**Generated IDs:** +```html +
+
+
+
+
+``` + +The ID format follows the pattern: +- Single letter = region type (`H`=Header, `L`=Left, `C`=Content, `R`=Right, `F`=Footer) +- Number = index within that region (0-based) +- Dash separates nesting levels + +This makes the DOM structure self-documenting and enables precise CSS targeting: + +```css +/* Target all right sidebars at any nesting level */ +[id$="-R-0"] { /* ... */ } + +/* Target deeply nested content areas */ +[id^="C-"][id*="-C-"] { /* ... */ } + +/* Target second element in any header */ +[id^="H-1"] { /* ... */ } +``` + +## Layout Variants + +### Two-Column Layout + +```blade + + + Navigation + + + + Main Content + + +``` + +### Three-Column Layout + +```blade + + + Left Sidebar + + + + Main Content + + + + Right Sidebar + + +``` + +### Full-Width Layout + +```blade + + + Header + + + + Full-Width Content + + +``` + +### Modal Layout + +```blade + + +

Edit Post

+
+ + +
...
+
+ + + Save + Cancel + +
+``` + +## Responsive Behavior + +HLCRF layouts adapt to screen size: + +```blade + + Sidebar + Content + Widgets + +``` + +**Result:** +- **Mobile:** Left → Content → Right (stacked vertically) +- **Tablet:** Left | Content (side-by-side) +- **Desktop:** Left | Content | Right (three columns) + +## Region Options + +### Collapsible Regions + +```blade + + Navigation Menu + +``` + +### Fixed Regions + +```blade + + Sticky Header + +``` + +### Scrollable Regions + +```blade + + Long Content + +``` + +### Region Width + +```blade + + Fixed width sidebar + + + + Percentage width sidebar + +``` + +## Conditional Regions + +### Show/Hide Based on Conditions + +```blade + + @auth + + + + @endauth + + + Main Content + + + @can('view-admin-sidebar') + + + + @endcan + +``` + +### Feature Flags + +```blade + + + Content + + + @feature('advanced-analytics') + + + + @endfeature + +``` + +## Styling + +### Custom Classes + +```blade + + + Header + + + + Content + + +``` + +### Slot Attributes + +```blade + + Dark Sidebar + +``` + +## Real-World Examples + +### Marketing Landing Page + +```blade + + {{-- Sticky header with CTA --}} + + + + + {{-- Hero section with sidebar --}} + + + + + + + + + + + + + + {{-- Footer with newsletter --}} + + + + + + + + + + + + +``` + +### E-Commerce Product Page + +```blade + + + + + + + + {{-- Product images --}} + + + + + {{-- Product details and buy box --}} + + + + + + + + {{-- Reviews and recommendations --}} + + + + + + + + + + + +``` + +### Blog with Ads + +```blade + + + + + + + + {{-- Sidebar navigation --}} + + + + + + {{-- Article content --}} + +
+

{{ $post->title }}

+ + {!! $post->content !!} + +
+ + +
+ + {{-- Widgets and ads --}} + + + + + + +
+
+ + + + +
+``` + +## Advanced Patterns + +### Dynamic Region Loading + +```blade + + + Main Content + + + + {{-- Load widgets based on page --}} + @foreach($widgets as $widget) + @include("widgets.{$widget}") + @endforeach + + +``` + +### Livewire Integration + +```blade + + + @livewire('global-search') + + + + @livewire('post-list') + + + + @livewire('post-filters') + + +``` + +### Portal Teleportation + +Send content to regions from anywhere: + +```blade +{{-- Page content --}} + +

My Page

+ + {{-- Component that teleports to header --}} + + Action 1 + Action 2 + +
+ +{{-- page-actions.blade.php component --}} + + {{ $slot }} + +``` + +## Implementation + +### Layout Component + +```php + + @if($header ?? false) +
+ {{ $header }} +
+ @endif + +
+ @if($left ?? false) +
+ {{ $left }} +
+ @endif + +
+ {{ $content ?? $slot }} +
+ + @if($right ?? false) +
+ {{ $right }} +
+ @endif +
+ + @if($footer ?? false) + + @endif + +``` + +## Testing + +### Component Testing + +```php +blade( + ' + Left + Content + Right + ' + ); + + $view->assertSee('Left'); + $view->assertSee('Content'); + $view->assertSee('Right'); + } + + public function test_optional_regions(): void + { + $view = $this->blade( + ' + Content Only + ' + ); + + $view->assertSee('Content Only'); + $view->assertDontSee('hlcrf-left'); + $view->assertDontSee('hlcrf-right'); + } +} +``` + +## Best Practices + +### 1. Use Semantic Regions + +```blade +{{-- ✅ Good - semantic use --}} +Global Navigation +Page Navigation +Main Content +Contextual Help + +{{-- ❌ Bad - misuse of regions --}} +Sidebar Content +Footer Content +``` + +### 2. Keep Regions Optional + +```blade +{{-- ✅ Good - gracefully handles missing regions --}} + + + Content works without sidebars + + +``` + +### 3. Consistent Widths + +```blade +{{-- ✅ Good - consistent sidebar widths --}} +Nav +Widgets +``` + +### 4. Mobile-First + +```blade +{{-- ✅ Good - stack on mobile --}} + +``` + +## Learn More + +- [Admin Components](/packages/admin#components) +- [Livewire Integration](/packages/admin#livewire) +- [Responsive Design](/patterns-guide/responsive-design) diff --git a/docs/build/php/patterns/repositories.md b/docs/build/php/patterns/repositories.md new file mode 100644 index 0000000..423d2d4 --- /dev/null +++ b/docs/build/php/patterns/repositories.md @@ -0,0 +1,327 @@ +# Repository Pattern + +Repositories abstract data access logic and provide a consistent interface for querying data. + +## When to Use Repositories + +Use repositories for: +- Complex query logic +- Multiple data sources +- Abstracting Eloquent/Query Builder +- Testing with fake data + +**Don't use repositories for:** +- Simple Eloquent queries (use models directly) +- Single-use queries +- Over-engineering simple applications + +## Basic Repository + +```php +orderByDesc('published_at') + ->paginate($perPage); + } + + public function findBySlug(string $slug): ?Post + { + return Post::where('slug', $slug) + ->where('status', 'published') + ->first(); + } + + public function findPopular(int $limit = 10): Collection + { + return Post::where('status', 'published') + ->where('views', '>', 1000) + ->orderByDesc('views') + ->limit($limit) + ->get(); + } + + public function findRecent(int $days = 7, int $limit = 10): Collection + { + return Post::where('status', 'published') + ->where('published_at', '>=', now()->subDays($days)) + ->orderByDesc('published_at') + ->limit($limit) + ->get(); + } +} +``` + +**Usage:** + +```php +$repository = app(PostRepository::class); +$posts = $repository->findPublished(); +$post = $repository->findBySlug('laravel-tutorial'); +``` + +## Repository with Interface + +```php +orderByDesc('published_at') + ->paginate($perPage); + } + + // ... other methods +} +``` + +**Binding:** + +```php +// Service Provider +$this->app->bind( + PostRepositoryInterface::class, + EloquentPostRepository::class +); +``` + +## Repository with Criteria + +```php +query = Post::query(); + } + + public function published(): self + { + $this->query->where('status', 'published'); + return $this; + } + + public function byAuthor(int $authorId): self + { + $this->query->where('author_id', $authorId); + return $this; + } + + public function inCategory(int $categoryId): self + { + $this->query->where('category_id', $categoryId); + return $this; + } + + public function recent(int $days = 7): self + { + $this->query->where('created_at', '>=', now()->subDays($days)); + return $this; + } + + public function get(): Collection + { + return $this->query->get(); + } + + public function paginate(int $perPage = 20) + { + return $this->query->paginate($perPage); + } +} +``` + +**Usage:** + +```php +$repository = app(PostRepository::class); + +// Chain criteria +$posts = $repository + ->published() + ->byAuthor($authorId) + ->recent(30) + ->paginate(); +``` + +## Repository with Caching + +```php +repository->findPublished($perPage); + }); + } + + public function findBySlug(string $slug): ?Post + { + return Cache::remember("posts.slug.{$slug}", 3600, function () use ($slug) { + return $this->repository->findBySlug($slug); + }); + } + + public function findPopular(int $limit = 10): Collection + { + return Cache::remember("posts.popular.{$limit}", 600, function () use ($limit) { + return $this->repository->findPopular($limit); + }); + } +} +``` + +## Testing with Repositories + +```php +create(['status' => 'published']); + Post::factory()->create(['status' => 'draft']); + + $posts = $repository->findPublished(); + + $this->assertCount(1, $posts); + $this->assertEquals('published', $posts->first()->status); + } + + public function test_finds_post_by_slug(): void + { + $repository = app(PostRepository::class); + + $post = Post::factory()->create([ + 'slug' => 'laravel-tutorial', + 'status' => 'published', + ]); + + $found = $repository->findBySlug('laravel-tutorial'); + + $this->assertEquals($post->id, $found->id); + } +} +``` + +## Best Practices + +### 1. Keep Methods Focused + +```php +// ✅ Good - specific method +public function findPublishedInCategory(int $categoryId): Collection +{ + return Post::where('status', 'published') + ->where('category_id', $categoryId) + ->get(); +} + +// ❌ Bad - too generic +public function find(array $criteria): Collection +{ + $query = Post::query(); + + foreach ($criteria as $key => $value) { + $query->where($key, $value); + } + + return $query->get(); +} +``` + +### 2. Return Collections or Models + +```php +// ✅ Good - returns typed result +public function findBySlug(string $slug): ?Post +{ + return Post::where('slug', $slug)->first(); +} + +// ❌ Bad - returns array +public function findBySlug(string $slug): ?array +{ + return Post::where('slug', $slug)->first()?->toArray(); +} +``` + +### 3. Use Constructor Injection + +```php +// ✅ Good - injected +public function __construct( + protected PostRepositoryInterface $posts +) {} + +// ❌ Bad - instantiated +public function __construct() +{ + $this->posts = new PostRepository(); +} +``` + +## Learn More + +- [Service Pattern →](/patterns-guide/services) +- [Actions Pattern →](/patterns-guide/actions) diff --git a/docs/build/php/patterns/seeders.md b/docs/build/php/patterns/seeders.md new file mode 100644 index 0000000..b72ec49 --- /dev/null +++ b/docs/build/php/patterns/seeders.md @@ -0,0 +1,656 @@ +# Seeder Discovery & Ordering + +Core PHP Framework provides automatic seeder discovery with dependency-based ordering. Define seeder dependencies using PHP attributes and let the framework handle execution order. + +## Overview + +Traditional Laravel seeders require manual ordering in `DatabaseSeeder`. Core PHP automatically discovers seeders across modules and orders them based on declared dependencies. + +### Traditional Approach + +```php +// database/seeders/DatabaseSeeder.php +class DatabaseSeeder extends Seeder +{ + public function run(): void + { + // Manual ordering - easy to get wrong + $this->call([ + WorkspaceSeeder::class, + UserSeeder::class, + CategorySeeder::class, + PostSeeder::class, + CommentSeeder::class, + ]); + } +} +``` + +**Problems:** +- Manual dependency management +- Order mistakes cause failures +- Scattered across modules but centrally managed +- Hard to maintain as modules grow + +### Discovery Approach + +```php +// Mod/Tenant/Database/Seeders/WorkspaceSeeder.php +#[SeederPriority(100)] +class WorkspaceSeeder extends Seeder +{ + public function run(): void { /* ... */ } +} + +// Mod/Blog/Database/Seeders/CategorySeeder.php +#[SeederPriority(50)] +#[SeederAfter(WorkspaceSeeder::class)] +class CategorySeeder extends Seeder +{ + public function run(): void { /* ... */ } +} + +// Mod/Blog/Database/Seeders/PostSeeder.php +#[SeederAfter(CategorySeeder::class)] +class PostSeeder extends Seeder +{ + public function run(): void { /* ... */ } +} +``` + +**Benefits:** +- Automatic discovery across modules +- Explicit dependency declarations +- Topological sorting handles execution order +- Circular dependency detection +- Each module manages its own seeders + +## Configuration + +### Enable Auto-Discovery + +```php +// config/core.php +'seeders' => [ + 'auto_discover' => env('SEEDERS_AUTO_DISCOVER', true), + 'paths' => [ + 'Mod/*/Database/Seeders', + 'Core/*/Database/Seeders', + 'Plug/*/Database/Seeders', + ], + 'exclude' => [ + 'DatabaseSeeder', + 'CoreDatabaseSeeder', + ], +], +``` + +### Create Core Seeder + +Create a root seeder that uses discovery: + +```php +getOrderedSeeders(); + + $this->call($seeders); + } +} +``` + +## Seeder Attributes + +### SeederPriority + +Set execution priority (higher = runs earlier): + +```php +count(3)->create(); + } +} +``` + +**Priority Ranges:** +- `100+` - Foundation data (workspaces, system records) +- `50-99` - Core domain data (users, categories) +- `1-49` - Feature data (posts, comments) +- `0` - Default priority +- `<0` - Post-processing (analytics, cache warming) + +### SeederAfter + +Run after specific seeders: + +```php +count(5)->create(); + } +} +``` + +### SeederBefore + +Run before specific seeders: + +```php +count(5)->create(); + } +} +``` + +### Combining Attributes + +Use multiple attributes for complex dependencies: + +```php +#[SeederPriority(50)] +#[SeederAfter(WorkspaceSeeder::class, UserSeeder::class)] +#[SeederBefore(CommentSeeder::class)] +class PostSeeder extends Seeder +{ + public function run(): void + { + Post::factory()->count(20)->create(); + } +} +``` + +## Execution Order + +### Topological Sorting + +The framework automatically orders seeders using topological sorting: + +``` +Given seeders: + - WorkspaceSeeder (priority: 100) + - UserSeeder (priority: 90, after: WorkspaceSeeder) + - CategorySeeder (priority: 50, after: WorkspaceSeeder) + - PostSeeder (priority: 40, after: CategorySeeder, UserSeeder) + - CommentSeeder (priority: 30, after: PostSeeder, UserSeeder) + +Execution order: + 1. WorkspaceSeeder (priority 100) + 2. UserSeeder (priority 90, depends on Workspace) + 3. CategorySeeder (priority 50, depends on Workspace) + 4. PostSeeder (priority 40, depends on Category & User) + 5. CommentSeeder (priority 30, depends on Post & User) +``` + +### Resolution Algorithm + +1. Group seeders by priority (high to low) +2. Within each priority group, perform topological sort +3. Detect circular dependencies +4. Execute in resolved order + +## Circular Dependency Detection + +The framework detects and prevents circular dependencies: + +```php +// ❌ This will throw CircularDependencyException + +#[SeederAfter(SeederB::class)] +class SeederA extends Seeder { } + +#[SeederAfter(SeederC::class)] +class SeederB extends Seeder { } + +#[SeederAfter(SeederA::class)] +class SeederC extends Seeder { } + +// Error: Circular dependency detected: SeederA → SeederB → SeederC → SeederA +``` + +## Module Seeders + +### Typical Module Structure + +``` +Mod/Blog/Database/Seeders/ +├── BlogSeeder.php # Optional: calls other seeders +├── CategorySeeder.php # Creates categories +├── PostSeeder.php # Creates posts +└── DemoContentSeeder.php # Creates demo data +``` + +### Module Seeder Example + +```php +call([ + CategorySeeder::class, + PostSeeder::class, + ]); + } +} +``` + +### Environment-Specific Seeding + +```php +#[SeederPriority(10)] +class DemoContentSeeder extends Seeder +{ + public function run(): void + { + // Only seed demo data in non-production + if (app()->environment('production')) { + return; + } + + Post::factory() + ->count(50) + ->published() + ->create(); + } +} +``` + +## Conditional Seeding + +### Feature Flags + +```php +class AnalyticsSeeder extends Seeder +{ + public function run(): void + { + if (! Feature::active('analytics')) { + $this->command->info('Skipping analytics seeder (feature disabled)'); + return; + } + + // Seed analytics data + } +} +``` + +### Configuration + +```php +class EmailSeeder extends Seeder +{ + public function run(): void + { + if (! config('modules.email.enabled')) { + return; + } + + EmailTemplate::factory()->count(10)->create(); + } +} +``` + +### Database Check + +```php +class MigrationSeeder extends Seeder +{ + public function run(): void + { + if (! Schema::hasTable('legacy_posts')) { + return; + } + + // Migrate legacy data + } +} +``` + +## Factory Integration + +Seeders commonly use factories: + +```php +count(5)->create(); + + // Create posts for each category + $categories->each(function ($category) { + Post::factory() + ->count(10) + ->for($category) + ->published() + ->create(); + }); + + // Create unpublished drafts + Post::factory() + ->count(5) + ->draft() + ->create(); + } +} +``` + +## Testing Seeders + +### Unit Testing + +```php +seed(PostSeeder::class); + + $this->assertDatabaseCount('posts', 20); + } + + public function test_posts_have_categories(): void + { + $this->seed(PostSeeder::class); + + $posts = Post::all(); + + $posts->each(function ($post) { + $this->assertNotNull($post->category_id); + }); + } +} +``` + +### Integration Testing + +```php +public function test_seeder_execution_order(): void +{ + $registry = app(SeederRegistry::class); + + $seeders = $registry->getOrderedSeeders(); + + $workspaceIndex = array_search(WorkspaceSeeder::class, $seeders); + $userIndex = array_search(UserSeeder::class, $seeders); + $postIndex = array_search(PostSeeder::class, $seeders); + + $this->assertLessThan($userIndex, $workspaceIndex); + $this->assertLessThan($postIndex, $userIndex); +} +``` + +### Circular Dependency Testing + +```php +public function test_detects_circular_dependencies(): void +{ + $this->expectException(CircularDependencyException::class); + + // Force circular dependency + $registry = app(SeederRegistry::class); + $registry->register([ + CircularA::class, + CircularB::class, + CircularC::class, + ]); + + $registry->getOrderedSeeders(); +} +``` + +## Performance + +### Chunking + +Seed large datasets in chunks: + +```php +public function run(): void +{ + $faker = Faker\Factory::create(); + + // Seed in chunks for better memory usage + for ($i = 0; $i < 10; $i++) { + Post::factory() + ->count(100) + ->create(); + + $this->command->info("Seeded batch " . ($i + 1) . "/10"); + } +} +``` + +### Database Transactions + +Wrap seeders in transactions for performance: + +```php +public function run(): void +{ + DB::transaction(function () { + Post::factory()->count(1000)->create(); + }); +} +``` + +### Disable Event Listeners + +Skip event listeners during seeding: + +```php +public function run(): void +{ + // Disable events for performance + Post::withoutEvents(function () { + Post::factory()->count(1000)->create(); + }); +} +``` + +## Debugging + +### Verbose Output + +```bash +# Show seeder execution details +php artisan db:seed --verbose + +# Show discovered seeders +php artisan db:seed --show-seeders +``` + +### Dry Run + +```bash +# Preview seeder order without executing +php artisan db:seed --dry-run +``` + +### Seeder Registry Inspection + +```php +$registry = app(SeederRegistry::class); + +// Get all discovered seeders +$seeders = $registry->getAllSeeders(); + +// Get execution order +$ordered = $registry->getOrderedSeeders(); + +// Get seeder metadata +$metadata = $registry->getMetadata(PostSeeder::class); +``` + +## Best Practices + +### 1. Use Priorities for Groups + +```php +// ✅ Good - clear priority groups +#[SeederPriority(100)] // Foundation +class WorkspaceSeeder { } + +#[SeederPriority(50)] // Core domain +class CategorySeeder { } + +#[SeederPriority(10)] // Feature data +class PostSeeder { } +``` + +### 2. Explicit Dependencies + +```php +// ✅ Good - explicit dependencies +#[SeederAfter(WorkspaceSeeder::class, CategorySeeder::class)] +class PostSeeder { } + +// ❌ Bad - implicit dependencies via priority alone +#[SeederPriority(40)] +class PostSeeder { } +``` + +### 3. Idempotent Seeders + +```php +// ✅ Good - safe to run multiple times +public function run(): void +{ + if (Category::exists()) { + return; + } + + Category::factory()->count(5)->create(); +} + +// ❌ Bad - creates duplicates +public function run(): void +{ + Category::factory()->count(5)->create(); +} +``` + +### 4. Environment Awareness + +```php +// ✅ Good - respects environment +public function run(): void +{ + $count = app()->environment('production') ? 10 : 100; + + Post::factory()->count($count)->create(); +} +``` + +### 5. Meaningful Names + +```php +// ✅ Good names +class WorkspaceSeeder { } +class BlogDemoContentSeeder { } +class LegacyPostMigrationSeeder { } + +// ❌ Bad names +class Seeder1 { } +class TestSeeder { } +class DataSeeder { } +``` + +## Running Seeders + +```bash +# Run all seeders +php artisan db:seed + +# Run specific seeder +php artisan db:seed --class=PostSeeder + +# Fresh database with seeding +php artisan migrate:fresh --seed + +# Seed specific modules +php artisan db:seed --module=Blog + +# Seed with environment +php artisan db:seed --env=staging +``` + +## Learn More + +- [Database Factories](/patterns-guide/factories) +- [Module System](/architecture/module-system) +- [Testing Seeders](/testing/seeders) diff --git a/docs/build/php/patterns/services.md b/docs/build/php/patterns/services.md new file mode 100644 index 0000000..a3b2f77 --- /dev/null +++ b/docs/build/php/patterns/services.md @@ -0,0 +1,445 @@ +# Service Pattern + +Services encapsulate business logic and coordinate between multiple models or external systems. + +## When to Use Services + +Use services for: +- Complex business logic involving multiple models +- External API integrations +- Operations requiring multiple steps +- Reusable functionality across controllers + +**Don't use services for:** +- Simple CRUD operations (use Actions) +- Single-model operations +- View logic (use View Models) + +## Basic Service + +```php +validateReadyForPublish($post); + + // Update post + $post->update([ + 'status' => 'published', + 'published_at' => now(), + 'published_by' => $user->id, + ]); + + // Generate SEO metadata + $this->generateSeoMetadata($post); + + // Notify subscribers + $this->notifySubscribers($post); + + // Update search index + $post->searchable(); + + return $post->fresh(); + } + + protected function validateReadyForPublish(Post $post): void + { + if (empty($post->title)) { + throw new ValidationException('Post must have a title'); + } + + if (empty($post->content)) { + throw new ValidationException('Post must have content'); + } + + if (!$post->featured_image) { + throw new ValidationException('Post must have a featured image'); + } + } + + protected function generateSeoMetadata(Post $post): void + { + if (empty($post->meta_description)) { + $post->meta_description = str($post->content) + ->stripTags() + ->limit(160); + } + + if (empty($post->og_image)) { + GenerateOgImageJob::dispatch($post); + } + + $post->save(); + } + + protected function notifySubscribers(Post $post): void + { + NotifySubscribersJob::dispatch($post); + } +} +``` + +**Usage:** + +```php +$service = app(PostPublishingService::class); +$publishedPost = $service->publish($post, auth()->user()); +``` + +## Service with Constructor Injection + +```php +apiUrl}/events", [ + 'api_key' => $this->apiKey, + 'event' => 'pageview', + 'url' => $url, + 'meta' => $meta, + ]); + } + + public function getPageViews(string $url, int $days = 30): int + { + return Cache::remember( + "analytics.pageviews.{$url}.{$days}", + now()->addHour(), + fn () => Http::get("{$this->apiUrl}/stats", [ + 'api_key' => $this->apiKey, + 'url' => $url, + 'days' => $days, + ])->json('views') + ); + } +} +``` + +**Service Provider:** + +```php +$this->app->singleton(AnalyticsService::class, function () { + return new AnalyticsService( + apiKey: config('analytics.api_key'), + apiUrl: config('analytics.api_url') + ); +}); +``` + +## Service Contracts + +Define interfaces for flexibility: + +```php +client->paymentIntents->create([ + 'amount' => $amount, + 'currency' => $currency, + 'metadata' => $meta, + ]); + + return new PaymentResult( + success: $intent->status === 'succeeded', + transactionId: $intent->id, + amount: $intent->amount, + currency: $intent->currency + ); + } + + // ... other methods +} +``` + +## Service with Dependencies + +```php +inventory->available($order->items)) { + return ProcessingResult::failed('Insufficient inventory'); + } + + // Reserve inventory + $this->inventory->reserve($order->items); + + try { + // Charge payment + $payment = $this->payment->charge( + amount: $order->total, + currency: $order->currency, + meta: ['order_id' => $order->id] + ); + + if (!$payment->success) { + $this->inventory->release($order->items); + return ProcessingResult::failed('Payment failed'); + } + + // Update order + $order->update([ + 'status' => 'paid', + 'transaction_id' => $payment->transactionId, + 'paid_at' => now(), + ]); + + // Send confirmation + $this->email->send( + to: $order->customer->email, + template: 'order-confirmation', + data: compact('order', 'payment') + ); + + return ProcessingResult::success($order); + + } catch (\Exception $e) { + $this->inventory->release($order->items); + throw $e; + } + } +} +``` + +## Service with Events + +```php +update([ + 'status' => 'scheduled', + 'publish_at' => $publishAt, + ]); + + // Dispatch event + event(new PostScheduled($post, $publishAt)); + + // Queue job to publish + PublishScheduledPostJob::dispatch($post) + ->delay($publishAt); + } + + public function publishScheduledPost(Post $post): void + { + if ($post->status !== 'scheduled') { + throw new InvalidStateException('Post is not scheduled'); + } + + $post->update([ + 'status' => 'published', + 'published_at' => now(), + ]); + + event(new PostPublished($post)); + } +} +``` + +## Testing Services + +```php +create(); + $post = Post::factory()->create(['status' => 'draft']); + + $result = $service->publish($post, $user); + + $this->assertEquals('published', $result->status); + $this->assertNotNull($result->published_at); + $this->assertEquals($user->id, $result->published_by); + } + + public function test_validates_post_before_publishing(): void + { + $service = app(PostPublishingService::class); + $user = User::factory()->create(); + $post = Post::factory()->create([ + 'title' => '', + 'status' => 'draft', + ]); + + $this->expectException(ValidationException::class); + + $service->publish($post, $user); + } + + public function test_generates_seo_metadata(): void + { + $service = app(PostPublishingService::class); + $user = User::factory()->create(); + $post = Post::factory()->create([ + 'content' => 'Long content here...', + 'meta_description' => null, + ]); + + $result = $service->publish($post, $user); + + $this->assertNotNull($result->meta_description); + } +} +``` + +## Best Practices + +### 1. Single Responsibility + +```php +// ✅ Good - focused service +class EmailVerificationService +{ + public function sendVerificationEmail(User $user): void {} + public function verify(string $token): bool {} + public function resend(User $user): void {} +} + +// ❌ Bad - too broad +class UserService +{ + public function create() {} + public function sendEmail() {} + public function processPayment() {} + public function generateReport() {} +} +``` + +### 2. Dependency Injection + +```php +// ✅ Good - injected dependencies +public function __construct( + protected EmailService $email, + protected PaymentGateway $payment +) {} + +// ❌ Bad - hard-coded dependencies +public function __construct() +{ + $this->email = new EmailService(); + $this->payment = new StripeGateway(); +} +``` + +### 3. Return Types + +```php +// ✅ Good - explicit return type +public function process(Order $order): ProcessingResult +{ + return new ProcessingResult(...); +} + +// ❌ Bad - no return type +public function process(Order $order) +{ + return [...]; +} +``` + +### 4. Error Handling + +```php +// ✅ Good - handle errors gracefully +public function process(Order $order): ProcessingResult +{ + try { + $result = $this->payment->charge($order->total); + + return ProcessingResult::success($result); + } catch (PaymentException $e) { + Log::error('Payment failed', ['order' => $order->id, 'error' => $e->getMessage()]); + + return ProcessingResult::failed($e->getMessage()); + } +} +``` + +## Learn More + +- [Actions Pattern →](/patterns-guide/actions) +- [Repository Pattern →](/patterns-guide/repositories) diff --git a/docs/build/php/quick-start.md b/docs/build/php/quick-start.md new file mode 100644 index 0000000..f7aca84 --- /dev/null +++ b/docs/build/php/quick-start.md @@ -0,0 +1,639 @@ +# Quick Start + +This tutorial walks you through creating your first module with Core PHP Framework. We'll build a simple blog module with posts, categories, and a public-facing website. + +## Prerequisites + +- Core PHP Framework installed ([Installation Guide](/guide/installation)) +- Database configured +- Basic Laravel knowledge + +## Step 1: Create the Module + +Use the Artisan command to scaffold a new module: + +```bash +php artisan make:mod Blog +``` + +This creates the following structure: + +``` +app/Mod/Blog/ +├── Boot.php # Module entry point +├── Actions/ # Business logic +├── Models/ # Eloquent models +├── Routes/ +│ ├── web.php # Public routes +│ ├── admin.php # Admin routes +│ └── api.php # API routes +├── Views/ # Blade templates +├── Migrations/ # Database migrations +├── Database/ +│ ├── Factories/ # Model factories +│ └── Seeders/ # Database seeders +└── config.php # Module configuration +``` + +## Step 2: Define Lifecycle Events + +Open `app/Mod/Blog/Boot.php` and declare which events your module listens to: + +```php + 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ApiRoutesRegistering::class => 'onApiRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('blog', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + + public function onAdmin(AdminPanelBooting $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + $event->menu(new BlogMenuProvider()); + } + + public function onApiRoutes(ApiRoutesRegistering $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/api.php'); + } +} +``` + +## Step 3: Create Models + +Create a `Post` model at `app/Mod/Blog/Models/Post.php`: + +```php + 'datetime', + ]; + + // Activity log configuration + protected array $activityLogAttributes = ['title', 'published_at']; + + public function category() + { + return $this->belongsTo(Category::class); + } + + public function scopePublished($query) + { + return $query->whereNotNull('published_at') + ->where('published_at', '<=', now()); + } +} +``` + +## Step 4: Create Migration + +Create a migration at `app/Mod/Blog/Migrations/2026_01_01_000001_create_blog_tables.php`: + +```php +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('blog_posts', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('category_id')->nullable()->constrained('blog_categories')->nullOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('content'); + $table->timestamp('published_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['workspace_id', 'published_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('blog_posts'); + Schema::dropIfExists('blog_categories'); + } +}; +``` + +Run the migration: + +```bash +php artisan migrate +``` + +## Step 5: Create Actions + +Create a `CreatePost` action at `app/Mod/Blog/Actions/CreatePost.php`: + +```php +update($data); + + return $post->fresh(); + } +} +``` + +## Step 6: Create Routes + +Define web routes in `app/Mod/Blog/Routes/web.php`: + +```php +group(function () { + Route::get('/blog', [BlogController::class, 'index'])->name('index'); + Route::get('/blog/{slug}', [BlogController::class, 'show'])->name('show'); + Route::get('/blog/category/{slug}', [BlogController::class, 'category'])->name('category'); +}); +``` + +Define admin routes in `app/Mod/Blog/Routes/admin.php`: + +```php +name('admin.blog.')->group(function () { + Route::resource('posts', PostController::class); + Route::resource('categories', CategoryController::class); + + Route::post('posts/{post}/publish', [PostController::class, 'publish']) + ->name('posts.publish'); +}); +``` + +## Step 7: Create Controllers + +Create a web controller at `app/Mod/Blog/Controllers/BlogController.php`: + +```php +published() + ->latest('published_at') + ->paginate(12); + + return view('blog::index', compact('posts')); + } + + public function show(string $slug) + { + $post = Post::with('category') + ->where('slug', $slug) + ->published() + ->firstOrFail(); + + return view('blog::show', compact('post')); + } + + public function category(string $slug) + { + $category = Category::where('slug', $slug)->firstOrFail(); + + $posts = Post::with('category') + ->where('category_id', $category->id) + ->published() + ->latest('published_at') + ->paginate(12); + + return view('blog::category', compact('category', 'posts')); + } +} +``` + +Create an admin controller at `app/Mod/Blog/Controllers/Admin/PostController.php`: + +```php +validated()); + + return redirect() + ->route('admin.blog.posts.edit', $post) + ->with('success', 'Post created successfully'); + } + + public function edit(Post $post) + { + return view('blog::admin.posts.edit', compact('post')); + } + + public function update(UpdatePostRequest $request, Post $post) + { + UpdatePost::run($post, $request->validated()); + + return back()->with('success', 'Post updated successfully'); + } + + public function destroy(Post $post) + { + $post->delete(); + + return redirect() + ->route('admin.blog.posts.index') + ->with('success', 'Post deleted successfully'); + } + + public function publish(Post $post) + { + UpdatePost::run($post, [ + 'published_at' => now(), + ]); + + return back()->with('success', 'Post published successfully'); + } +} +``` + +## Step 8: Create Admin Menu + +Create a menu provider at `app/Mod/Blog/BlogMenuProvider.php`: + +```php +icon('newspaper') + ->priority(30) + ->children([ + MenuItemBuilder::make('Posts') + ->route('admin.blog.posts.index') + ->icon('document-text'), + + MenuItemBuilder::make('Categories') + ->route('admin.blog.categories.index') + ->icon('folder'), + + MenuItemBuilder::make('New Post') + ->route('admin.blog.posts.create') + ->icon('plus-circle'), + ]) + ->build(), + ]; + } +} +``` + +## Step 9: Create Views + +Create a blog index view at `app/Mod/Blog/Views/index.blade.php`: + +```blade +@extends('layouts.app') + +@section('content') +
+

Blog

+ +
+ @foreach($posts as $post) + + @endforeach +
+ +
+ {{ $posts->links() }} +
+
+@endsection +``` + +## Step 10: Create Seeder (Optional) + +Create a seeder at `app/Mod/Blog/Database/Seeders/BlogSeeder.php`: + +```php + 'Technology', + 'slug' => 'technology', + 'description' => 'Technology news and articles', + ]); + + $design = Category::create([ + 'name' => 'Design', + 'slug' => 'design', + 'description' => 'Design tips and inspiration', + ]); + + // Create posts + Post::create([ + 'category_id' => $tech->id, + 'title' => 'Getting Started with Core PHP', + 'slug' => 'getting-started-with-core-php', + 'excerpt' => 'Learn how to build modular Laravel applications.', + 'content' => '

Full article content here...

', + 'published_at' => now()->subDays(7), + ]); + + Post::create([ + 'category_id' => $design->id, + 'title' => 'Modern UI Design Patterns', + 'slug' => 'modern-ui-design-patterns', + 'excerpt' => 'Explore contemporary design patterns for web applications.', + 'content' => '

Full article content here...

', + 'published_at' => now()->subDays(3), + ]); + } +} +``` + +Run the seeder: + +```bash +php artisan db:seed --class=Mod\\Blog\\Database\\Seeders\\BlogSeeder +``` + +Or use auto-discovery: + +```bash +php artisan db:seed +``` + +## Step 11: Test Your Module + +Visit your blog: + +``` +http://your-app.test/blog +``` + +Access the admin panel: + +``` +http://your-app.test/admin/blog/posts +``` + +## Next Steps + +Now that you've created your first module, explore more advanced features: + +### Add API Endpoints + +Create API resources and controllers for programmatic access: + +- [API Package Documentation](/packages/api) +- [OpenAPI Documentation](/packages/api#openapi-documentation) + +### Add Activity Logging + +Track changes to your posts: + +- [Activity Logging Guide](/patterns-guide/activity-logging) + +### Add Search Functionality + +Integrate with the unified search system: + +- [Search Integration](/patterns-guide/search) + +### Add Workspace Caching + +Optimize database queries with team-scoped caching: + +- [Workspace Caching](/patterns-guide/multi-tenancy#workspace-caching) + +### Add Tests + +Create feature tests for your module: + +```bash +php artisan make:test Mod/Blog/PostTest +``` + +Example test: + +```php + 'Test Post', + 'content' => 'Test content', + ]); + + $this->assertDatabaseHas('blog_posts', [ + 'title' => 'Test Post', + 'slug' => 'test-post', + ]); + } + + public function test_published_posts_are_visible(): void + { + Post::factory()->create([ + 'published_at' => now()->subDay(), + ]); + + $response = $this->get('/blog'); + + $response->assertStatus(200); + } +} +``` + +## Learn More + +- [Architecture Overview](/architecture/lifecycle-events) +- [Actions Pattern](/patterns-guide/actions) +- [Multi-Tenancy Guide](/patterns-guide/multi-tenancy) +- [Admin Panel Customization](/packages/admin) diff --git a/docs/build/php/search.md b/docs/build/php/search.md new file mode 100644 index 0000000..5b7f39b --- /dev/null +++ b/docs/build/php/search.md @@ -0,0 +1,607 @@ +# Unified Search + +Powerful cross-model search with analytics, suggestions, and highlighting. + +## Basic Usage + +### Setting Up Search + +```php + $this->title, + 'content' => strip_tags($this->content), + 'category' => $this->category->name, + 'tags' => $this->tags->pluck('name')->join(', '), + 'author' => $this->author->name, + ]; + } +} +``` + +### Searching + +```php +use Mod\Blog\Models\Post; + +// Simple search +$results = Post::search('laravel tutorial')->get(); + +// Paginated search +$results = Post::search('php') + ->paginate(20); + +// With constraints +$results = Post::search('api') + ->where('status', 'published') + ->where('category_id', 5) + ->get(); +``` + +## Unified Search + +Search across multiple models: + +```php +use Core\Search\Unified; + +$search = app(Unified::class); + +// Search everything +$results = $search->search('api documentation', [ + \Mod\Blog\Models\Post::class, + \Mod\Docs\Models\Page::class, + \Mod\Shop\Models\Product::class, +]); + +// Returns grouped results +[ + 'posts' => [...], + 'pages' => [...], + 'products' => [...], +] +``` + +### Weighted Results + +```php +// Boost specific models +$results = $search->search('tutorial', [ + \Mod\Blog\Models\Post::class => 2.0, // 2x weight + \Mod\Docs\Models\Page::class => 1.5, // 1.5x weight + \Mod\Video\Models\Video::class => 1.0, // Normal weight +]); +``` + +### Result Limiting + +```php +// Limit results per model +$results = $search->search('api', [ + \Mod\Blog\Models\Post::class, + \Mod\Docs\Models\Page::class, +], perModel: 5); // Max 5 results per model +``` + +## Search Analytics + +Track search queries and clicks: + +```php +use Core\Search\Analytics\SearchAnalytics; + +$analytics = app(SearchAnalytics::class); + +// Record search +$analytics->recordSearch( + query: 'laravel tutorial', + results: 42, + user: auth()->user() +); + +// Record click-through +$analytics->recordClick( + query: 'laravel tutorial', + resultId: $post->id, + resultType: Post::class, + position: 3 // 3rd result clicked +); +``` + +### Analytics Queries + +```php +// Popular searches +$popular = $analytics->popularSearches(limit: 10); + +// Recent searches +$recent = $analytics->recentSearches(limit: 20); + +// Zero-result searches (need attention!) +$empty = $analytics->emptySearches(); + +// Click-through rate +$ctr = $analytics->clickThroughRate('laravel tutorial'); + +// Average position of clicks +$avgPosition = $analytics->averageClickPosition('api docs'); +``` + +### Search Dashboard + +```php +use Core\Search\Analytics\SearchAnalytics; + +class SearchDashboard extends Component +{ + public function render() + { + $analytics = app(SearchAnalytics::class); + + return view('search.dashboard', [ + 'totalSearches' => $analytics->totalSearches(), + 'uniqueQueries' => $analytics->uniqueQueries(), + 'avgResultsPerSearch' => $analytics->averageResults(), + 'popularSearches' => $analytics->popularSearches(10), + 'emptySearches' => $analytics->emptySearches(), + ]); + } +} +``` + +## Search Suggestions + +Autocomplete and query suggestions: + +```php +use Core\Search\Suggestions\SearchSuggestions; + +$suggestions = app(SearchSuggestions::class); + +// Get suggestions for partial query +$results = $suggestions->suggest('lar', [ + \Mod\Blog\Models\Post::class, +]); + +// Returns: +[ + 'laravel', + 'laravel tutorial', + 'laravel api', + 'laravel testing', +] +``` + +### Configuration + +```php +// config/search.php +return [ + 'suggestions' => [ + 'enabled' => true, + 'min_length' => 2, // Minimum query length + 'max_results' => 10, // Max suggestions + 'cache_ttl' => 3600, // Cache for 1 hour + 'learn_from_searches' => true, // Build from analytics + ], +]; +``` + +### Livewire Autocomplete + +```php +class SearchBox extends Component +{ + public $query = ''; + public $suggestions = []; + + public function updatedQuery() + { + if (strlen($this->query) < 2) { + $this->suggestions = []; + return; + } + + $suggestions = app(SearchSuggestions::class); + $this->suggestions = $suggestions->suggest($this->query, [ + Post::class, + Page::class, + ]); + } + + public function render() + { + return view('livewire.search-box'); + } +} +``` + +```blade +
+ + + @if(count($suggestions) > 0) +
    + @foreach($suggestions as $suggestion) +
  • + {{ $suggestion }} +
  • + @endforeach +
+ @endif +
+``` + +## Highlighting + +Highlight matching terms in results: + +```php +use Core\Search\Support\SearchHighlighter; + +$highlighter = app(SearchHighlighter::class); + +// Highlight text +$highlighted = $highlighter->highlight( + text: $post->title, + query: 'laravel tutorial', + tag: 'mark' +); + +// Returns: "Getting started with Laravel Tutorial" +``` + +### Configuration + +```php +// config/search.php +return [ + 'highlighting' => [ + 'enabled' => true, + 'tag' => 'mark', // HTML tag to use + 'class' => 'highlight', // CSS class + 'max_length' => 200, // Snippet length + 'context' => 50, // Context around match + ], +]; +``` + +### Blade Component + +```blade + +

{{ $post->title }}

+

{!! highlight($post->excerpt, $query) !!}

+
+``` + +**Helper Function:** + +```php +// helpers.php +function highlight(string $text, string $query, string $tag = 'mark'): string +{ + return app(SearchHighlighter::class)->highlight($text, $query, $tag); +} +``` + +## Filtering & Faceting + +### Adding Filters + +```php +// Search with filters +$results = Post::search('tutorial') + ->where('status', 'published') + ->where('category_id', 5) + ->where('created_at', '>=', now()->subDays(30)) + ->get(); +``` + +### Faceted Search + +```php +use Laravel\Scout\Builder; + +// Get facet counts +$facets = Post::search('api') + ->with('category') + ->get() + ->groupBy('category.name') + ->map->count(); + +// Returns: +[ + 'Tutorials' => 12, + 'Documentation' => 8, + 'News' => 5, +] +``` + +### Livewire Facets + +```php +class FacetedSearch extends Component +{ + public $query = ''; + public $category = null; + public $status = 'published'; + + public function render() + { + $results = Post::search($this->query) + ->when($this->category, fn($q) => $q->where('category_id', $this->category)) + ->where('status', $this->status) + ->paginate(20); + + $facets = Post::search($this->query) + ->where('status', $this->status) + ->get() + ->groupBy('category.name') + ->map->count(); + + return view('livewire.faceted-search', [ + 'results' => $results, + 'facets' => $facets, + ]); + } +} +``` + +## Scout Drivers + +### Meilisearch (Recommended) + +```bash +# Install Meilisearch +brew install meilisearch + +# Start server +meilisearch --master-key=YOUR_MASTER_KEY +``` + +**Configuration:** + +```php +// config/scout.php +return [ + 'driver' => 'meilisearch', + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + ], +]; +``` + +### Database Driver + +For small applications: + +```php +// config/scout.php +return [ + 'driver' => 'database', +]; +``` + +**Limitations:** +- No relevance scoring +- No typo tolerance +- Slower for large datasets +- Good for < 10,000 records + +### Algolia + +```php +// config/scout.php +return [ + 'driver' => 'algolia', + + 'algolia' => [ + 'id' => env('ALGOLIA_APP_ID'), + 'secret' => env('ALGOLIA_SECRET'), + ], +]; +``` + +## Indexing + +### Manual Indexing + +```bash +# Index all records +php artisan scout:import "Mod\Blog\Models\Post" + +# Flush index +php artisan scout:flush "Mod\Blog\Models\Post" + +# Re-import +php artisan scout:flush "Mod\Blog\Models\Post" +php artisan scout:import "Mod\Blog\Models\Post" +``` + +### Conditional Indexing + +```php +class Post extends Model +{ + use Searchable; + + public function shouldBeSearchable(): bool + { + return $this->status === 'published'; + } +} +``` + +### Batch Indexing + +```php +// Automatically batched +Post::chunk(100, function ($posts) { + $posts->searchable(); +}); +``` + +## Performance + +### Eager Loading + +```php +// ✅ Good - eager load relationships +$results = Post::search('tutorial') + ->with(['category', 'author', 'tags']) + ->get(); + +// ❌ Bad - N+1 queries +$results = Post::search('tutorial')->get(); +foreach ($results as $post) { + echo $post->category->name; // Query per post +} +``` + +### Result Caching + +```php +use Illuminate\Support\Facades\Cache; + +// Cache search results +$results = Cache::remember( + "search:{$query}:{$page}", + now()->addMinutes(5), + fn () => Post::search($query)->paginate(20) +); +``` + +### Query Throttling + +```php +// Rate limit search endpoint +Route::middleware('throttle:60,1') + ->get('/search', [SearchController::class, 'index']); +``` + +## Best Practices + +### 1. Index Only What's Needed + +```php +// ✅ Good - essential fields only +public function toSearchableArray(): array +{ + return [ + 'title' => $this->title, + 'content' => strip_tags($this->content), + ]; +} + +// ❌ Bad - too much data +public function toSearchableArray(): array +{ + return $this->toArray(); // Includes everything! +} +``` + +### 2. Use Conditional Indexing + +```php +// ✅ Good - index published only +public function shouldBeSearchable(): bool +{ + return $this->status === 'published'; +} + +// ❌ Bad - index drafts +public function shouldBeSearchable(): bool +{ + return true; +} +``` + +### 3. Track Analytics + +```php +// ✅ Good - record searches +$analytics->recordSearch($query, $results->count()); + +// Use analytics to improve search +$emptySearches = $analytics->emptySearches(); +// Add synonyms, fix typos, expand content +``` + +### 4. Provide Suggestions + +```php +// ✅ Good - help users find content + + +@if($suggestions) +
    + @foreach($suggestions as $suggestion) +
  • {{ $suggestion }}
  • + @endforeach +
+@endif +``` + +## Testing + +```php +use Tests\TestCase; +use Mod\Blog\Models\Post; + +class SearchTest extends TestCase +{ + public function test_searches_posts(): void + { + Post::factory()->create(['title' => 'Laravel Tutorial']); + Post::factory()->create(['title' => 'PHP Basics']); + + $results = Post::search('laravel')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Laravel Tutorial', $results->first()->title); + } + + public function test_filters_results(): void + { + Post::factory()->create([ + 'title' => 'Laravel Tutorial', + 'status' => 'published', + ]); + + Post::factory()->create([ + 'title' => 'Laravel Guide', + 'status' => 'draft', + ]); + + $results = Post::search('laravel') + ->where('status', 'published') + ->get(); + + $this->assertCount(1, $results); + } +} +``` + +## Learn More + +- [Configuration →](/core/configuration) +- [Global Search →](/packages/admin/search) diff --git a/docs/build/php/security.md b/docs/build/php/security.md new file mode 100644 index 0000000..e124e52 --- /dev/null +++ b/docs/build/php/security.md @@ -0,0 +1,609 @@ +# Security Overview + +Core PHP Framework is built with security as a foundational principle. This guide covers the security features, best practices, and considerations for building secure applications. + +## Security Features + +### Multi-Tenant Isolation + +Complete data isolation between workspaces and namespaces: + +```php +// Workspace-scoped models +class Post extends Model +{ + use BelongsToWorkspace; // Automatic workspace isolation +} + +// Namespace-scoped models +class Page extends Model +{ + use BelongsToNamespace; // Automatic namespace isolation +} +``` + +**Protection:** +- Automatic query scoping +- Workspace context validation +- Strict mode enforcement +- Cache isolation + +[Learn more about Multi-Tenancy →](/architecture/multi-tenancy) +[Learn more about Namespaces →](/security/namespaces) + +### API Security + +#### Secure API Keys + +API keys are hashed with bcrypt and never stored in plaintext: + +```php +$apiKey = ApiKey::create([ + 'name' => 'Mobile App', + 'workspace_id' => $workspace->id, + 'scopes' => ['posts:read', 'posts:write'], +]); + +// Plaintext key only shown once! +$plaintext = $apiKey->plaintext_key; // sk_live_... + +// Hash stored in database +// Verification uses bcrypt's secure comparison +``` + +**Features:** +- Bcrypt hashing +- Key rotation with grace period +- Scope-based permissions +- Rate limiting per key +- Usage tracking + +#### Scope Enforcement + +Fine-grained API permissions: + +```php +// Middleware enforces scopes +Route::middleware('scope:posts:write') + ->post('/posts', [PostController::class, 'store']); + +// Check scopes in code +if (! $request->user()->tokenCan('posts:delete')) { + abort(403, 'Insufficient permissions'); +} +``` + +**Available Scopes:** +- `posts:read`, `posts:write`, `posts:delete` +- `categories:read`, `categories:write` +- `analytics:read` +- `webhooks:manage` +- `keys:manage` + +#### Rate Limiting + +Tier-based rate limiting prevents abuse: + +```php +// config/core-api.php +'rate_limits' => [ + 'tiers' => [ + 'free' => ['requests' => 1000, 'window' => 60], + 'pro' => ['requests' => 10000, 'window' => 60], + 'enterprise' => ['requests' => null], // unlimited + ], +], +``` + +**Response Headers:** +``` +X-RateLimit-Limit: 10000 +X-RateLimit-Remaining: 9995 +X-RateLimit-Reset: 1640995200 +``` + +#### Webhook Signatures + +HMAC-SHA256 signatures prevent tampering: + +```php +// Webhook payload signing +$signature = hash_hmac( + 'sha256', + $timestamp . '.' . $payload, + $webhookSecret +); + +// Verification +if (! hash_equals($expected, $signature)) { + abort(401, 'Invalid signature'); +} + +// Timestamp validation prevents replay attacks +if (abs(time() - $timestamp) > 300) { + abort(401, 'Request too old'); +} +``` + +[Learn more about API Security →](/packages/api) + +### SQL Injection Prevention + +Multi-layer protection for database queries: + +```php +// config/core-mcp.php +'database' => [ + 'validation' => [ + 'enabled' => true, + 'blocked_keywords' => ['INSERT', 'UPDATE', 'DELETE', 'DROP'], + 'blocked_tables' => ['users', 'api_keys', 'password_resets'], + 'whitelist_enabled' => false, + ], +], +``` + +**Validation Layers:** +1. **Keyword blocking** - Block dangerous SQL keywords +2. **Table restrictions** - Prevent access to sensitive tables +3. **Pattern detection** - Detect SQL injection patterns +4. **Whitelist validation** - Optional pre-approved queries +5. **Read-only connections** - Separate connection without write access + +**Example:** + +```php +class QueryDatabaseTool extends Tool +{ + public function handle(Request $request): Response + { + $query = $request->input('query'); + + // Validates against all layers + $this->validator->validate($query); + + // Execute on read-only connection + $results = DB::connection('mcp_readonly')->select($query); + + return Response::success(['rows' => $results]); + } +} +``` + +[Learn more about MCP Security →](/packages/mcp) + +### Security Headers + +Comprehensive security headers protect against common attacks: + +```php +// config/core.php +'security_headers' => [ + 'csp' => [ + 'enabled' => true, + 'report_only' => false, + 'directives' => [ + 'default-src' => ["'self'"], + 'script-src' => ["'self'", "'nonce'"], + 'style-src' => ["'self'", "'unsafe-inline'"], + 'img-src' => ["'self'", 'data:', 'https:'], + 'connect-src' => ["'self'"], + 'font-src' => ["'self'", 'data:'], + 'object-src' => ["'none'"], + 'base-uri' => ["'self'"], + 'form-action' => ["'self'"], + 'frame-ancestors' => ["'none'"], + ], + ], + 'hsts' => [ + 'enabled' => true, + 'max_age' => 31536000, // 1 year + 'include_subdomains' => true, + 'preload' => true, + ], + 'x_frame_options' => 'DENY', + 'x_content_type_options' => 'nosniff', + 'x_xss_protection' => '1; mode=block', + 'referrer_policy' => 'strict-origin-when-cross-origin', +], +``` + +**Protection Against:** +- **XSS** - Content Security Policy blocks inline scripts +- **Clickjacking** - X-Frame-Options prevents iframe embedding +- **MITM** - HSTS enforces HTTPS +- **Content Type Sniffing** - X-Content-Type-Options +- **Data Leakage** - Referrer Policy controls referrer info + +**CSP Nonces:** + +```blade + +``` + +### Input Validation & Sanitization + +Comprehensive input handling: + +```php +use Core\Input\Sanitiser; + +$sanitiser = app(Sanitiser::class); + +// Sanitize user input +$clean = $sanitiser->sanitize($userInput, [ + 'strip_tags' => true, + 'trim' => true, + 'escape_html' => true, +]); + +// Sanitize HTML content +$safeHtml = $sanitiser->sanitizeHtml($content, [ + 'allowed_tags' => ['p', 'br', 'strong', 'em', 'a'], + 'allowed_attributes' => ['href', 'title'], +]); +``` + +**Features:** +- HTML tag stripping +- XSS prevention +- SQL injection prevention (via Eloquent) +- CSRF protection (Laravel default) +- Mass assignment protection + +### Email Security + +Disposable email detection and validation: + +```php +use Core\Mail\EmailShield; + +$shield = app(EmailShield::class); + +$result = $shield->validate('user@tempmail.com'); + +if (! $result->isValid) { + // Email failed validation + // Reasons: disposable, syntax error, MX record invalid + return back()->withErrors(['email' => $result->reason]); +} +``` + +**Checks:** +- Disposable email providers +- Syntax validation +- MX record verification +- Common typo detection +- Role-based email detection (abuse@, admin@, etc.) + +### Authentication Security + +#### Password Hashing + +Laravel's bcrypt with automatic rehashing: + +```php +// Hashing +$hashed = bcrypt('password'); + +// Verification with automatic rehash +if (Hash::check($password, $user->password)) { + // Re-hash if using old cost + if (Hash::needsRehash($user->password)) { + $user->password = bcrypt($password); + $user->save(); + } +} +``` + +#### Two-Factor Authentication + +TOTP-based 2FA support: + +```php +use Core\Mod\Tenant\Concerns\TwoFactorAuthenticatable; + +class User extends Model +{ + use TwoFactorAuthenticatable; +} + +// Enable 2FA +$secret = $user->enableTwoFactorAuth(); +$qrCode = $user->getTwoFactorQrCode(); + +// Verify code +if ($user->verifyTwoFactorCode($code)) { + // Code valid +} +``` + +#### Session Security + +```php +// config/session.php +'secure' => env('SESSION_SECURE_COOKIE', true), +'http_only' => true, +'same_site' => 'lax', +'lifetime' => 120, +``` + +**Features:** +- Secure cookies (HTTPS only) +- HTTP-only cookies (no JavaScript access) +- SameSite protection +- Session regeneration on login +- Automatic logout on inactivity + +### IP Blocklist + +Automatic blocking of malicious IPs: + +```php +use Core\Bouncer\BlocklistService; + +$blocklist = app(BlocklistService::class); + +// Check if IP is blocked +if ($blocklist->isBlocked($ip)) { + abort(403, 'Access denied'); +} + +// Add IP to blocklist +$blocklist->block($ip, reason: 'Brute force attempt', duration: 3600); + +// Remove from blocklist +$blocklist->unblock($ip); +``` + +**Features:** +- Temporary and permanent blocks +- Reason tracking +- Automatic expiry +- Admin interface +- Integration with rate limiting + +### Action Gate + +Request whitelisting for sensitive operations: + +```php +use Core\Bouncer\Gate\Attributes\Action; + +#[Action('post.publish', description: 'Publish a blog post')] +class PublishPost +{ + use Action; + + public function handle(Post $post): Post + { + $post->update(['published_at' => now()]); + return $post; + } +} +``` + +**Modes:** +- **Training Mode** - Log all requests without blocking +- **Enforcement Mode** - Block unauthorized requests +- **Audit Mode** - Log + alert on violations + +**Configuration:** + +```php +// config/core.php +'bouncer' => [ + 'enabled' => true, + 'training_mode' => false, + 'block_unauthorized' => true, + 'log_all_requests' => true, +], +``` + +### Activity Logging + +Comprehensive audit trail: + +```php +use Core\Activity\Concerns\LogsActivity; + +class Post extends Model +{ + use LogsActivity; + + protected array $activityLogAttributes = ['title', 'status', 'published_at']; +} + +// Changes logged automatically +$post->update(['title' => 'New Title']); + +// Retrieve activity +$activity = Activity::forSubject($post) + ->latest() + ->get(); +``` + +**GDPR Compliance:** +- Optional IP address logging (disabled by default) +- Automatic anonymization after configurable period +- User data deletion on account closure +- Activity log pruning + +[Learn more about Activity Logging →](/patterns-guide/activity-logging) + +## Security Best Practices + +### 1. Use Workspace/Namespace Scoping + +Always scope data to workspaces or namespaces: + +```php +// ✅ Good - automatic scoping +class Post extends Model +{ + use BelongsToWorkspace; +} + +// ❌ Bad - no isolation +class Post extends Model { } +``` + +### 2. Validate All Input + +Never trust user input: + +```php +// ✅ Good - validation +$validated = $request->validate([ + 'title' => 'required|max:255', + 'content' => 'required', +]); + +// ❌ Bad - no validation +$post->update($request->all()); +``` + +### 3. Use Parameterized Queries + +Eloquent provides automatic protection: + +```php +// ✅ Good - parameterized +Post::where('title', $title)->get(); + +// ❌ Bad - vulnerable to SQL injection +DB::select("SELECT * FROM posts WHERE title = '{$title}'"); +``` + +### 4. Implement Rate Limiting + +Protect all public endpoints: + +```php +// ✅ Good - rate limited +Route::middleware('throttle:60,1') + ->post('/api/posts', [PostController::class, 'store']); + +// ❌ Bad - no rate limiting +Route::post('/api/posts', [PostController::class, 'store']); +``` + +### 5. Use HTTPS + +Always enforce HTTPS in production: + +```php +// app/Providers/AppServiceProvider.php +public function boot(): void +{ + if (app()->environment('production')) { + URL::forceScheme('https'); + } +} +``` + +### 6. Implement Authorization + +Use policies for authorization: + +```php +// ✅ Good - policy check +$this->authorize('update', $post); + +// ❌ Bad - no authorization +$post->update($request->validated()); +``` + +### 7. Sanitize Output + +Blade automatically escapes output: + +```blade +{{-- ✅ Good - auto-escaped --}} +

{{ $post->title }}

+ +{{-- ❌ Bad - unescaped (only when needed) --}} +
{!! $post->content !!}
+``` + +### 8. Rotate Secrets + +Regularly rotate secrets and API keys: + +```php +// API key rotation +$newKey = $apiKey->rotate(); + +// Session secret rotation (in .env) +php artisan key:generate +``` + +### 9. Monitor Security Events + +Log security-relevant events: + +```php +activity() + ->causedBy($user) + ->performedOn($resource) + ->withProperties(['ip' => $ip, 'user_agent' => $userAgent]) + ->log('unauthorized_access_attempt'); +``` + +### 10. Keep Dependencies Updated + +```bash +# Check for security updates +composer audit + +# Update dependencies +composer update +``` + +## Reporting Security Vulnerabilities + +If you discover a security vulnerability, please email: + +**support@host.uk.com** + +Do not create public GitHub issues for security vulnerabilities. + +**Response Timeline:** +- **Critical**: 24 hours +- **High**: 48 hours +- **Medium**: 7 days +- **Low**: 14 days + +[Full Disclosure Policy →](/security/responsible-disclosure) + +## Security Checklist + +Before deploying to production: + +- [ ] HTTPS enforced +- [ ] Security headers configured +- [ ] Rate limiting enabled +- [ ] CSRF protection active +- [ ] Input validation implemented +- [ ] SQL injection protections verified +- [ ] XSS protections enabled +- [ ] Authentication secure (2FA optional) +- [ ] Authorization policies in place +- [ ] Activity logging enabled +- [ ] Error messages sanitized (no stack traces in production) +- [ ] Debug mode disabled (`APP_DEBUG=false`) +- [ ] Database credentials secured +- [ ] API keys rotated +- [ ] Backups configured +- [ ] Monitoring/alerting active + +## Learn More + +- [Namespaces & Entitlements →](/security/namespaces) +- [API Security →](/packages/api) +- [MCP Security →](/packages/mcp) +- [Multi-Tenancy →](/architecture/multi-tenancy) +- [Responsible Disclosure →](/security/responsible-disclosure) diff --git a/docs/build/php/seeder-system.md b/docs/build/php/seeder-system.md new file mode 100644 index 0000000..f16416d --- /dev/null +++ b/docs/build/php/seeder-system.md @@ -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 +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](/core/modules) +- [Service Contracts](/core/service-contracts) +- [Configuration](/core/configuration) diff --git a/docs/build/php/seo.md b/docs/build/php/seo.md new file mode 100644 index 0000000..91418a8 --- /dev/null +++ b/docs/build/php/seo.md @@ -0,0 +1,500 @@ +# SEO Tools + +Comprehensive SEO tools including metadata management, sitemap generation, structured data, and OG image generation. + +## SEO Metadata + +### Basic Usage + +```php +use Core\Seo\SeoMetadata; + +$seo = app(SeoMetadata::class); + +// Set page metadata +$seo->title('Complete Laravel Tutorial') + ->description('Learn Laravel from scratch with this comprehensive tutorial') + ->keywords(['laravel', 'php', 'tutorial', 'web development']) + ->canonical(url()->current()); +``` + +### Blade Output + +```blade + + + + {!! $seo->render() !!} + + +``` + +**Rendered Output:** + +```html +Complete Laravel Tutorial + + + +``` + +### Open Graph Tags + +```php +$seo->og([ + 'title' => 'Complete Laravel Tutorial', + 'description' => 'Learn Laravel from scratch...', + 'image' => cdn('images/laravel-tutorial.jpg'), + 'type' => 'article', + 'url' => url()->current(), +]); +``` + +**Rendered:** + +```html + + + + + +``` + +### Twitter Cards + +```php +$seo->twitter([ + 'card' => 'summary_large_image', + 'site' => '@yourhandle', + 'creator' => '@authorhandle', + 'title' => 'Complete Laravel Tutorial', + 'description' => 'Learn Laravel from scratch...', + 'image' => cdn('images/laravel-tutorial.jpg'), +]); +``` + +## Dynamic OG Images + +Generate OG images on-the-fly: + +```php +use Core\Seo\Jobs\GenerateOgImageJob; + +// Queue image generation +GenerateOgImageJob::dispatch($post, [ + 'title' => $post->title, + 'subtitle' => $post->category->name, + 'author' => $post->author->name, + 'template' => 'blog-post', +]); + +// Use generated image +$seo->og([ + 'image' => $post->og_image_url, +]); +``` + +### OG Image Templates + +```php +// config/seo.php +return [ + 'og_images' => [ + 'templates' => [ + 'blog-post' => [ + 'width' => 1200, + 'height' => 630, + 'background' => '#1e293b', + 'title_color' => '#ffffff', + 'title_size' => 64, + 'subtitle_color' => '#94a3b8', + 'subtitle_size' => 32, + ], + 'product' => [ + 'width' => 1200, + 'height' => 630, + 'background' => '#0f172a', + 'overlay' => true, + ], + ], + ], +]; +``` + +### Validating OG Images + +```php +use Core\Seo\Validation\OgImageValidator; + +$validator = app(OgImageValidator::class); + +// Validate image meets requirements +$result = $validator->validate($imagePath); + +if (!$result->valid) { + foreach ($result->errors as $error) { + echo $error; // "Image width must be at least 1200px" + } +} +``` + +**Requirements:** +- Minimum 1200×630px (recommended) +- Maximum 8MB file size +- Supported formats: JPG, PNG, WebP +- Aspect ratio: 1.91:1 + +## Sitemaps + +### Generating Sitemaps + +```php +use Core\Seo\Controllers\SitemapController; + +// Auto-generated route: /sitemap.xml +// Lists all public URLs + +// Custom sitemap +Route::get('/sitemap.xml', [SitemapController::class, 'index']); +``` + +### Adding URLs + +```php +namespace Mod\Blog; + +use Core\Events\WebRoutesRegistering; + +class Boot +{ + public function onWebRoutes(WebRoutesRegistering $event): void + { + // Posts automatically included in sitemap + $event->sitemap(function ($sitemap) { + Post::where('status', 'published') + ->each(function ($post) use ($sitemap) { + $sitemap->add( + url: route('blog.show', $post), + lastmod: $post->updated_at, + changefreq: 'weekly', + priority: 0.8 + ); + }); + }); + } +} +``` + +### Sitemap Index + +For large sites: + +```xml + + + + + https://example.com/sitemap-posts.xml + 2026-01-26T12:00:00+00:00 + + + https://example.com/sitemap-products.xml + 2026-01-25T10:30:00+00:00 + + +``` + +## Structured Data + +### JSON-LD Schema + +```php +$seo->schema([ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'headline' => $post->title, + 'description' => $post->excerpt, + 'image' => cdn($post->featured_image), + 'datePublished' => $post->published_at->toIso8601String(), + 'dateModified' => $post->updated_at->toIso8601String(), + 'author' => [ + '@type' => 'Person', + 'name' => $post->author->name, + ], +]); +``` + +**Rendered:** + +```html + +``` + +### Common Schema Types + +**Blog Post:** + +```php +$seo->schema([ + '@type' => 'BlogPosting', + 'headline' => $post->title, + 'image' => cdn($post->image), + 'author' => ['@type' => 'Person', 'name' => $author->name], + 'publisher' => [ + '@type' => 'Organization', + 'name' => config('app.name'), + 'logo' => cdn('logo.png'), + ], +]); +``` + +**Product:** + +```php +$seo->schema([ + '@type' => 'Product', + 'name' => $product->name, + 'image' => cdn($product->image), + 'description' => $product->description, + 'sku' => $product->sku, + 'offers' => [ + '@type' => 'Offer', + 'price' => $product->price, + 'priceCurrency' => 'GBP', + 'availability' => 'https://schema.org/InStock', + ], +]); +``` + +**Breadcrumbs:** + +```php +$seo->schema([ + '@type' => 'BreadcrumbList', + 'itemListElement' => [ + [ + '@type' => 'ListItem', + 'position' => 1, + 'name' => 'Home', + 'item' => route('home'), + ], + [ + '@type' => 'ListItem', + 'position' => 2, + 'name' => 'Blog', + 'item' => route('blog.index'), + ], + [ + '@type' => 'ListItem', + 'position' => 3, + 'name' => $post->title, + 'item' => route('blog.show', $post), + ], + ], +]); +``` + +### Testing Structured Data + +```bash +php artisan seo:test-structured-data +``` + +**Or programmatically:** + +```php +use Core\Seo\Validation\StructuredDataTester; + +$tester = app(StructuredDataTester::class); + +$result = $tester->test($jsonLd); + +if (!$result->valid) { + foreach ($result->errors as $error) { + echo $error; // "Missing required property: datePublished" + } +} +``` + +## Canonical URLs + +### Setting Canonical + +```php +// Explicit canonical +$seo->canonical('https://example.com/blog/laravel-tutorial'); + +// Auto-detect +$seo->canonical(url()->current()); + +// Remove query parameters +$seo->canonical(url()->current(), stripQuery: true); +``` + +### Auditing Canonicals + +```bash +php artisan seo:audit-canonical +``` + +**Checks for:** +- Missing canonical tags +- Self-referencing issues +- HTTPS/HTTP mismatches +- Duplicate content + +**Example Output:** + +``` +Canonical URL Audit +=================== + +✓ 1,234 pages have canonical tags +✗ 45 pages missing canonical tags +✗ 12 pages with incorrect HTTPS +⚠ 8 pages with duplicate content + +Issues: +- /blog/post-1 missing canonical +- /shop/product-5 using HTTP instead of HTTPS +``` + +## SEO Scoring + +Track SEO quality over time: + +```php +use Core\Seo\Analytics\SeoScoreTrend; + +$trend = app(SeoScoreTrend::class); + +// Record current SEO score +$trend->record($post, [ + 'title_length' => strlen($post->title), + 'has_meta_description' => !empty($post->meta_description), + 'has_og_image' => !empty($post->og_image), + 'has_canonical' => !empty($post->canonical_url), + 'structured_data' => !empty($post->schema), +]); + +// View trends +$scores = $trend->history($post, days: 30); +``` + +### SEO Score Calculation + +```php +// config/seo.php +return [ + 'scoring' => [ + 'title_length' => ['min' => 30, 'max' => 60, 'points' => 10], + 'meta_description' => ['min' => 120, 'max' => 160, 'points' => 10], + 'has_og_image' => ['points' => 15], + 'has_canonical' => ['points' => 10], + 'has_structured_data' => ['points' => 15], + 'image_alt_text' => ['points' => 10], + 'heading_hierarchy' => ['points' => 10], + 'internal_links' => ['min' => 3, 'points' => 10], + 'external_links' => ['min' => 1, 'points' => 5], + 'word_count' => ['min' => 300, 'points' => 15], + ], +]; +``` + +## Best Practices + +### 1. Always Set Metadata + +```php +// ✅ Good - complete metadata +$seo->title('Laravel Tutorial') + ->description('Learn Laravel...') + ->canonical(url()->current()) + ->og(['image' => cdn('image.jpg')]); + +// ❌ Bad - missing metadata +$seo->title('Laravel Tutorial'); +``` + +### 2. Use Unique Titles & Descriptions + +```php +// ✅ Good - unique per page +$seo->title($post->title . ' - Blog') + ->description($post->excerpt); + +// ❌ Bad - same title everywhere +$seo->title(config('app.name')); +``` + +### 3. Generate OG Images + +```php +// ✅ Good - custom OG image +GenerateOgImageJob::dispatch($post); + +// ❌ Bad - generic logo +$seo->og(['image' => cdn('logo.png')]); +``` + +### 4. Validate Structured Data + +```bash +# Test before deploying +php artisan seo:test-structured-data + +# Check with Google Rich Results Test +# https://search.google.com/test/rich-results +``` + +## Testing + +```php +use Tests\TestCase; +use Core\Seo\SeoMetadata; + +class SeoTest extends TestCase +{ + public function test_renders_metadata(): void + { + $seo = app(SeoMetadata::class); + + $seo->title('Test Page') + ->description('Test description'); + + $html = $seo->render(); + + $this->assertStringContainsString('Test Page', $html); + $this->assertStringContainsString('name="description"', $html); + } + + public function test_generates_og_image(): void + { + $post = Post::factory()->create(); + + GenerateOgImageJob::dispatch($post); + + $this->assertNotNull($post->fresh()->og_image_url); + $this->assertFileExists(storage_path("app/og-images/{$post->id}.jpg")); + } +} +``` + +## Learn More + +- [Configuration →](/core/configuration) +- [Media Processing →](/core/media) diff --git a/docs/build/php/service-contracts.md b/docs/build/php/service-contracts.md new file mode 100644 index 0000000..a2a90f9 --- /dev/null +++ b/docs/build/php/service-contracts.md @@ -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 + '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 + '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](/core/modules) +- [Lifecycle Events](/core/events) +- [Seeder System](/core/seeder-system) diff --git a/docs/build/php/tenancy.md b/docs/build/php/tenancy.md new file mode 100644 index 0000000..49a3749 --- /dev/null +++ b/docs/build/php/tenancy.md @@ -0,0 +1,514 @@ +# Multi-Tenancy + +Core PHP Framework provides robust multi-tenancy with dual-level isolation: **Workspaces** for team/agency management and **Namespaces** for service isolation and billing contexts. + +## Overview + +The tenancy system supports three common patterns: + +1. **Personal** - Individual users with personal namespaces +2. **Agency/Team** - Workspaces with multiple users managing client namespaces +3. **White-Label** - Operators creating workspace + namespace pairs for customers + +## Workspaces + +Workspaces represent a team, agency, or organization. Multiple users can belong to a workspace. + +### Creating Workspaces + +```php +use Core\Mod\Tenant\Models\Workspace; + +$workspace = Workspace::create([ + 'name' => 'Acme Corporation', + 'slug' => 'acme-corp', + 'tier' => 'business', +]); + +// Add user to workspace +$workspace->users()->attach($user->id, [ + 'role' => 'admin', +]); +``` + +### Workspace Scoping + +Use the `BelongsToWorkspace` trait to automatically scope models: + +```php +use Core\Mod\Tenant\Concerns\BelongsToWorkspace; + +class Post extends Model +{ + use BelongsToWorkspace; +} + +// Queries automatically scoped to current workspace +$posts = Post::all(); // Only posts in current workspace + +// Create within workspace +$post = Post::create([ + 'title' => 'My Post', +]); // workspace_id automatically set +``` + +### Workspace Context + +The current workspace is resolved from: + +1. Session (for web requests) +2. `X-Workspace-ID` header (for API requests) +3. Query parameter `workspace_id` +4. User's default workspace (fallback) + +```php +// Get current workspace +$workspace = workspace(); + +// Check if workspace context is set +if (workspace()) { + // Workspace context available +} + +// Manually set workspace +Workspace::setCurrent($workspace); +``` + +## Namespaces + +Namespaces provide service isolation and are the **billing context** for entitlements. A namespace can be owned by a **User** (personal) or a **Workspace** (agency/client). + +### Why Namespaces? + +- **Service Isolation** - Each namespace has separate storage, API quotas, features +- **Billing Context** - Packages and entitlements are attached to namespaces +- **Agency Pattern** - One workspace can manage many client namespaces +- **White-Label** - Operators can provision namespace + workspace pairs + +### Namespace Ownership + +Namespaces use polymorphic ownership: + +```php +use Core\Mod\Tenant\Models\Namespace_; + +// Personal namespace (owned by User) +$namespace = Namespace_::create([ + 'name' => 'Personal', + 'slug' => 'personal', + 'owner_type' => User::class, + 'owner_id' => $user->id, + 'is_default' => true, +]); + +// Client namespace (owned by Workspace) +$namespace = Namespace_::create([ + 'name' => 'Client: Acme Corp', + 'slug' => 'client-acme', + 'owner_type' => Workspace::class, + 'owner_id' => $workspace->id, + 'workspace_id' => $workspace->id, // For billing aggregation +]); +``` + +### Namespace Scoping + +Use the `BelongsToNamespace` trait for namespace-specific data: + +```php +use Core\Mod\Tenant\Concerns\BelongsToNamespace; + +class Media extends Model +{ + use BelongsToNamespace; +} + +// Queries automatically scoped to current namespace +$media = Media::all(); + +// With caching +$media = Media::ownedByCurrentNamespaceCached(ttl: 300); +``` + +### Namespace Context + +The current namespace is resolved from: + +1. Session (for web requests) +2. `X-Namespace-ID` header (for API requests) +3. Query parameter `namespace_id` +4. User's default namespace (fallback) + +```php +// Get current namespace +$namespace = namespace_context(); + +// Manually set namespace +Namespace_::setCurrent($namespace); +``` + +### Accessible Namespaces + +Get all namespaces a user can access: + +```php +use Core\Mod\Tenant\Services\NamespaceService; + +$service = app(NamespaceService::class); + +// Get all accessible namespaces +$namespaces = $service->getAccessibleNamespaces($user); + +// Grouped by type +$grouped = $service->getGroupedNamespaces($user); +// Returns: +// [ +// 'personal' => [...], // User-owned namespaces +// 'workspaces' => [ // Workspace-owned namespaces +// 'Workspace Name' => [...], +// ] +// ] +``` + +## Entitlements Integration + +Namespaces are the billing context for entitlements: + +```php +use Core\Mod\Tenant\Services\EntitlementService; + +$entitlements = app(EntitlementService::class); + +// Check if namespace has access to feature +$result = $entitlements->can($namespace, 'storage', quantity: 1073741824); + +if ($result->isDenied()) { + return back()->with('error', $result->getMessage()); +} + +// Record usage +$entitlements->recordUsage($namespace, 'api_calls', quantity: 1); + +// Get current usage +$usage = $entitlements->getUsage($namespace, 'storage'); +``` + +[Learn more about Entitlements →](/security/namespaces) + +## Multi-Level Isolation + +You can use both workspace and namespace scoping: + +```php +class Invoice extends Model +{ + use BelongsToWorkspace, BelongsToNamespace; +} + +// Query scoped to both workspace AND namespace +$invoices = Invoice::all(); +``` + +## Workspace Caching + +The framework provides workspace-isolated caching: + +```php +use Core\Mod\Tenant\Concerns\HasWorkspaceCache; + +class Post extends Model +{ + use BelongsToWorkspace, HasWorkspaceCache; +} + +// Cache automatically isolated per workspace +$posts = Post::ownedByCurrentWorkspaceCached(ttl: 600); + +// Manual workspace caching +$value = workspace_cache()->remember('stats', 600, function () { + return $this->calculateStats(); +}); + +// Clear workspace cache +workspace_cache()->flush(); +``` + +### Cache Tags + +When using Redis/Memcached, caches are tagged with workspace ID: + +```php +// Automatically uses tag: "workspace:{id}" +workspace_cache()->put('key', 'value', 600); + +// Clear all cache for workspace +workspace_cache()->flush(); // Clears all tags for current workspace +``` + +## Context Resolution + +### Middleware + +Require workspace or namespace context: + +```php +use Core\Mod\Tenant\Middleware\RequireWorkspaceContext; + +Route::middleware(RequireWorkspaceContext::class)->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index']); +}); +``` + +### Manual Resolution + +```php +use Core\Mod\Tenant\Services\NamespaceService; + +$service = app(NamespaceService::class); + +// Resolve namespace from request +$namespace = $service->resolveFromRequest($request); + +// Get default namespace for user +$namespace = $service->getDefaultNamespace($user); + +// Set current namespace +$service->setCurrentNamespace($namespace); +``` + +## Workspace Invitations + +Invite users to join workspaces: + +```php +use Core\Mod\Tenant\Models\WorkspaceInvitation; + +$invitation = WorkspaceInvitation::create([ + 'workspace_id' => $workspace->id, + 'email' => 'user@example.com', + 'role' => 'member', + 'invited_by' => $currentUser->id, +]); + +// Send invitation email +$invitation->notify(new WorkspaceInvitationNotification($invitation)); + +// Accept invitation +$invitation->accept($user); +``` + +## Usage Patterns + +### Personal User (No Workspace) + +```php +// User has personal namespace +$user = User::find(1); +$namespace = $user->namespaces()->where('is_default', true)->first(); + +// Can access services via namespace +$result = $entitlements->can($namespace, 'storage'); +``` + +### Agency with Clients + +```php +// Agency workspace owns multiple client namespaces +$workspace = Workspace::where('slug', 'agency')->first(); + +// Each client gets their own namespace +$clientNamespace = Namespace_::create([ + 'name' => 'Client: Acme', + 'owner_type' => Workspace::class, + 'owner_id' => $workspace->id, + 'workspace_id' => $workspace->id, +]); + +// Client's resources scoped to their namespace +$media = Media::where('namespace_id', $clientNamespace->id)->get(); + +// Workspace usage aggregated across all client namespaces +$totalUsage = $workspace->namespaces()->sum('storage_used'); +``` + +### White-Label Operator + +```php +// Operator creates workspace + namespace for customer +$workspace = Workspace::create([ + 'name' => 'Customer Corp', + 'slug' => 'customer-corp', +]); + +$namespace = Namespace_::create([ + 'name' => 'Customer Corp Services', + 'owner_type' => Workspace::class, + 'owner_id' => $workspace->id, + 'workspace_id' => $workspace->id, +]); + +// Attach package to namespace +$namespace->packages()->attach($packageId, [ + 'expires_at' => now()->addYear(), +]); + +// Add user to workspace +$workspace->users()->attach($userId, ['role' => 'admin']); +``` + +## Testing + +### Setting Workspace Context + +```php +use Core\Mod\Tenant\Models\Workspace; + +class PostTest extends TestCase +{ + public function test_creates_post_in_workspace(): void + { + $workspace = Workspace::factory()->create(); + Workspace::setCurrent($workspace); + + $post = Post::create(['title' => 'Test']); + + $this->assertEquals($workspace->id, $post->workspace_id); + } +} +``` + +### Setting Namespace Context + +```php +use Core\Mod\Tenant\Models\Namespace_; + +class MediaTest extends TestCase +{ + public function test_uploads_media_to_namespace(): void + { + $namespace = Namespace_::factory()->create(); + Namespace_::setCurrent($namespace); + + $media = Media::create(['filename' => 'test.jpg']); + + $this->assertEquals($namespace->id, $media->namespace_id); + } +} +``` + +## Database Schema + +### Workspaces Table + +```sql +CREATE TABLE workspaces ( + id BIGINT PRIMARY KEY, + uuid VARCHAR(36) UNIQUE, + name VARCHAR(255), + slug VARCHAR(255) UNIQUE, + tier VARCHAR(50), + settings JSON, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### Namespaces Table + +```sql +CREATE TABLE namespaces ( + id BIGINT PRIMARY KEY, + uuid VARCHAR(36) UNIQUE, + name VARCHAR(255), + slug VARCHAR(255), + owner_type VARCHAR(255), -- User::class or Workspace::class + owner_id BIGINT, + workspace_id BIGINT NULL, -- Billing context + settings JSON, + is_default BOOLEAN, + is_active BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP, + + INDEX idx_owner (owner_type, owner_id), + INDEX idx_workspace (workspace_id) +); +``` + +### Workspace Users Table + +```sql +CREATE TABLE workspace_user ( + id BIGINT PRIMARY KEY, + workspace_id BIGINT, + user_id BIGINT, + role VARCHAR(50), + joined_at TIMESTAMP, + + UNIQUE KEY (workspace_id, user_id) +); +``` + +## Best Practices + +### 1. Always Use Scoping Traits + +```php +// ✅ Good +class Post extends Model +{ + use BelongsToWorkspace; +} + +// ❌ Bad - manual scoping +Post::where('workspace_id', workspace()->id)->get(); +``` + +### 2. Use Namespace for Service Resources + +```php +// ✅ Good - namespace scoped +class Media extends Model +{ + use BelongsToNamespace; +} + +// ❌ Bad - workspace scoped for service resources +class Media extends Model +{ + use BelongsToWorkspace; // Wrong context +} +``` + +### 3. Cache with Workspace Isolation + +```php +// ✅ Good +$stats = workspace_cache()->remember('stats', 600, fn () => $this->calculate()); + +// ❌ Bad - global cache conflicts +$stats = Cache::remember('stats', 600, fn () => $this->calculate()); +``` + +### 4. Validate Entitlements Before Actions + +```php +// ✅ Good +public function store(Request $request) +{ + $result = $entitlements->can(namespace_context(), 'posts', quantity: 1); + + if ($result->isDenied()) { + return back()->with('error', $result->getMessage()); + } + + return CreatePost::run($request->validated()); +} +``` + +## Learn More + +- [Namespaces & Entitlements →](/security/namespaces) +- [Architecture: Multi-Tenancy →](/architecture/multi-tenancy) +- [Workspace Caching →](#workspace-caching) +- [Testing Multi-Tenancy →](/guide/testing#multi-tenancy) diff --git a/docs/build/php/testing.md b/docs/build/php/testing.md new file mode 100644 index 0000000..a6a5213 --- /dev/null +++ b/docs/build/php/testing.md @@ -0,0 +1,497 @@ +# Testing Guide + +Comprehensive guide to testing Core PHP Framework applications. + +## Running Tests + +```bash +# Run all tests +composer test + +# Run specific test file +./vendor/bin/phpunit packages/core-php/tests/Feature/ActivityLogServiceTest.php + +# Run tests with coverage +./vendor/bin/phpunit --coverage-html coverage + +# Run specific test method +./vendor/bin/phpunit --filter test_creates_post +``` + +## Test Structure + +``` +tests/ +├── Feature/ # Integration tests +│ ├── ApiTest.php +│ ├── AuthTest.php +│ └── PostTest.php +├── Unit/ # Unit tests +│ ├── ActionTest.php +│ └── ServiceTest.php +└── TestCase.php # Base test case +``` + +## Writing Feature Tests + +Feature tests test complete workflows: + +```php +create(); + + $response = $this->actingAs($user) + ->post('/posts', [ + 'title' => 'Test Post', + 'content' => 'Test content', + 'status' => 'draft', + ]); + + $response->assertRedirect(); + + $this->assertDatabaseHas('posts', [ + 'title' => 'Test Post', + 'author_id' => $user->id, + ]); + } + + public function test_guest_cannot_create_post(): void + { + $response = $this->post('/posts', [ + 'title' => 'Test Post', + 'content' => 'Test content', + ]); + + $response->assertRedirect(route('login')); + } + + public function test_user_can_view_own_posts(): void + { + $user = User::factory()->create(); + $post = Post::factory()->create(['author_id' => $user->id]); + + $response = $this->actingAs($user) + ->get("/posts/{$post->id}"); + + $response->assertOk(); + $response->assertSee($post->title); + } +} +``` + +## Writing Unit Tests + +Unit tests test isolated components: + +```php + 'Test Post', + 'content' => 'Test content', + 'status' => 'draft', + ]); + + $this->assertInstanceOf(Post::class, $post); + $this->assertEquals('Test Post', $post->title); + $this->assertDatabaseHas('posts', ['id' => $post->id]); + } + + public function test_generates_slug_from_title(): void + { + $post = CreatePost::run([ + 'title' => 'Test Post', + 'content' => 'Content', + ]); + + $this->assertEquals('test-post', $post->slug); + } + + public function test_throws_exception_for_invalid_data(): void + { + $this->expectException(ValidationException::class); + + CreatePost::run([ + 'title' => '', // Invalid + 'content' => 'Content', + ]); + } +} +``` + +## Database Testing + +### Factories + +```php + $this->faker->sentence(), + 'content' => $this->faker->paragraphs(3, true), + 'status' => 'draft', + 'author_id' => User::factory(), + ]; + } + + public function published(): self + { + return $this->state([ + 'status' => 'published', + 'published_at' => now(), + ]); + } + + public function draft(): self + { + return $this->state(['status' => 'draft']); + } +} +``` + +**Usage:** + +```php +// Create single post +$post = Post::factory()->create(); + +// Create published post +$post = Post::factory()->published()->create(); + +// Create multiple posts +$posts = Post::factory()->count(10)->create(); + +// Create with specific attributes +$post = Post::factory()->create([ + 'title' => 'Specific Title', +]); +``` + +### Database Assertions + +```php +// Assert record exists +$this->assertDatabaseHas('posts', [ + 'title' => 'Test Post', + 'status' => 'published', +]); + +// Assert record doesn't exist +$this->assertDatabaseMissing('posts', [ + 'title' => 'Deleted Post', +]); + +// Assert record count +$this->assertDatabaseCount('posts', 10); + +// Assert model exists +$this->assertModelExists($post); + +// Assert model deleted +$this->assertSoftDeleted($post); +``` + +## API Testing + +```php +create(); + Sanctum::actingAs($user, ['posts:read']); + + Post::factory()->count(5)->published()->create(); + + $response = $this->getJson('/api/v1/posts'); + + $response->assertOk(); + $response->assertJsonCount(5, 'data'); + $response->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'title', 'status', 'created_at'], + ], + ]); + } + + public function test_creates_post(): void + { + $user = User::factory()->create(); + Sanctum::actingAs($user, ['posts:write']); + + $response = $this->postJson('/api/v1/posts', [ + 'title' => 'API Test Post', + 'content' => 'Test content', + ]); + + $response->assertCreated(); + $response->assertJson([ + 'title' => 'API Test Post', + ]); + + $this->assertDatabaseHas('posts', [ + 'title' => 'API Test Post', + ]); + } + + public function test_requires_authentication(): void + { + $response = $this->getJson('/api/v1/posts'); + + $response->assertUnauthorized(); + } + + public function test_requires_correct_scope(): void + { + $user = User::factory()->create(); + Sanctum::actingAs($user, ['posts:read']); // Missing write scope + + $response = $this->postJson('/api/v1/posts', [ + 'title' => 'Test', + 'content' => 'Content', + ]); + + $response->assertForbidden(); + } +} +``` + +## Livewire Testing + +```php +create(); + + Livewire::test(PostEditor::class, ['post' => $post]) + ->assertSee($post->title) + ->assertSee('Save'); + } + + public function test_updates_post(): void + { + $post = Post::factory()->create(['title' => 'Original']); + + Livewire::test(PostEditor::class, ['post' => $post]) + ->set('title', 'Updated Title') + ->call('save') + ->assertDispatched('post-updated'); + + $this->assertEquals('Updated Title', $post->fresh()->title); + } + + public function test_validates_input(): void + { + $post = Post::factory()->create(); + + Livewire::test(PostEditor::class, ['post' => $post]) + ->set('title', '') + ->call('save') + ->assertHasErrors(['title' => 'required']); + } +} +``` + +## Mocking + +### Mocking Services + +```php +use Mockery; +use Mod\Payment\Services\PaymentService; + +public function test_processes_order_with_mock(): void +{ + $mock = Mockery::mock(PaymentService::class); + $mock->shouldReceive('charge') + ->once() + ->with(1000, 'GBP') + ->andReturn(new PaymentResult(success: true)); + + $this->app->instance(PaymentService::class, $mock); + + $order = Order::factory()->create(); + $result = $this->orderService->process($order); + + $this->assertTrue($result->success); +} +``` + +### Mocking Facades + +```php +use Illuminate\Support\Facades\Storage; + +public function test_uploads_file(): void +{ + Storage::fake('s3'); + + $this->post('/upload', [ + 'file' => UploadedFile::fake()->image('photo.jpg'), + ]); + + Storage::disk('s3')->assertExists('photos/photo.jpg'); +} +``` + +### Mocking Events + +```php +use Illuminate\Support\Facades\Event; +use Mod\Blog\Events\PostPublished; + +public function test_fires_event(): void +{ + Event::fake([PostPublished::class]); + + $post = Post::factory()->create(); + $service->publish($post); + + Event::assertDispatched(PostPublished::class, function ($event) use ($post) { + return $event->post->id === $post->id; + }); +} +``` + +## Testing Workspace Isolation + +```php +public function test_scopes_to_workspace(): void +{ + $workspace1 = Workspace::factory()->create(); + $workspace2 = Workspace::factory()->create(); + + $post1 = Post::factory()->create(['workspace_id' => $workspace1->id]); + $post2 = Post::factory()->create(['workspace_id' => $workspace2->id]); + + // Acting as user in workspace1 + $user = User::factory()->create(['workspace_id' => $workspace1->id]); + + $posts = Post::all(); // Should only see workspace1's posts + + $this->assertCount(1, $posts); + $this->assertEquals($post1->id, $posts->first()->id); +} +``` + +## Best Practices + +### 1. Test One Thing + +```php +// ✅ Good - tests one behavior +public function test_creates_post(): void +{ + $post = CreatePost::run([...]); + $this->assertInstanceOf(Post::class, $post); +} + +// ❌ Bad - tests multiple things +public function test_post_operations(): void +{ + $post = CreatePost::run([...]); + $this->assertInstanceOf(Post::class, $post); + + $post->publish(); + $this->assertEquals('published', $post->status); + + $post->delete(); + $this->assertSoftDeleted($post); +} +``` + +### 2. Use Descriptive Names + +```php +// ✅ Good +public function test_user_can_create_post_with_valid_data(): void + +// ❌ Bad +public function test_create(): void +``` + +### 3. Arrange, Act, Assert + +```php +public function test_publishes_post(): void +{ + // Arrange + $post = Post::factory()->create(['status' => 'draft']); + $user = User::factory()->create(); + + // Act + $result = $service->publish($post, $user); + + // Assert + $this->assertEquals('published', $result->status); + $this->assertNotNull($result->published_at); +} +``` + +### 4. Clean Up After Tests + +```php +use Illuminate\Foundation\Testing\RefreshDatabase; + +class PostTest extends TestCase +{ + use RefreshDatabase; // Resets database after each test + + public function test_something(): void + { + // Test code + } +} +``` + +## Learn More + +- [Actions Pattern →](/patterns-guide/actions) +- [Service Pattern →](/patterns-guide/services) +- [Contributing →](/contributing) diff --git a/docs/deploy/docker.md b/docs/deploy/docker.md new file mode 100644 index 0000000..832ebea --- /dev/null +++ b/docs/deploy/docker.md @@ -0,0 +1,208 @@ +# Docker Deployment + +Deploy containerised applications with Docker, Docker Compose, and container orchestrators. + +## Building Images + +Build Docker images with `core build`: + +```bash +# Auto-detect Dockerfile and build +core build --type docker + +# Custom image name +core build --type docker --image ghcr.io/myorg/myapp + +# Build and push to registry +core build --type docker --image ghcr.io/myorg/myapp --push +``` + +## Docker Compose + +### Basic Setup + +`docker-compose.yml`: + +```yaml +version: '3.8' + +services: + app: + image: ghcr.io/myorg/myapp:latest + ports: + - "8080:8080" + environment: + - APP_ENV=production + - DATABASE_URL=postgres://db:5432/myapp + depends_on: + - db + - redis + + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=myapp + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password + secrets: + - db_password + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: + +secrets: + db_password: + file: ./secrets/db_password.txt +``` + +### Deploy + +```bash +# Start services +docker compose up -d + +# View logs +docker compose logs -f app + +# Scale horizontally +docker compose up -d --scale app=3 + +# Update to new version +docker compose pull && docker compose up -d +``` + +## Multi-Stage Builds + +Optimised Dockerfile for PHP applications: + +```dockerfile +# Build stage +FROM composer:2 AS deps +WORKDIR /app +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-scripts --prefer-dist + +# Production stage +FROM dunglas/frankenphp:latest +WORKDIR /app + +COPY --from=deps /app/vendor ./vendor +COPY . . + +RUN composer dump-autoload --optimize + +EXPOSE 8080 +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] +``` + +## Health Checks + +Add health checks for orchestrator integration: + +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 +``` + +Or in docker-compose: + +```yaml +services: + app: + image: myapp:latest + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s +``` + +## Environment Configuration + +### Using .env Files + +```yaml +services: + app: + image: myapp:latest + env_file: + - .env + - .env.local +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `APP_ENV` | Environment (production, staging) | +| `APP_DEBUG` | Enable debug mode | +| `DATABASE_URL` | Database connection string | +| `REDIS_URL` | Redis connection string | +| `LOG_LEVEL` | Logging verbosity | + +## Registry Authentication + +### GitHub Container Registry + +```bash +# Login +echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin + +# Push +docker push ghcr.io/myorg/myapp:latest +``` + +### AWS ECR + +```bash +# Login +aws ecr get-login-password --region eu-west-1 | \ + docker login --username AWS --password-stdin 123456789.dkr.ecr.eu-west-1.amazonaws.com + +# Push +docker push 123456789.dkr.ecr.eu-west-1.amazonaws.com/myapp:latest +``` + +## Orchestration + +### Docker Swarm + +```bash +# Initialise swarm +docker swarm init + +# Deploy stack +docker stack deploy -c docker-compose.yml myapp + +# Scale service +docker service scale myapp_app=5 + +# Rolling update +docker service update --image myapp:v2 myapp_app +``` + +### Kubernetes + +Generate Kubernetes manifests from Compose: + +```bash +# Using kompose +kompose convert -f docker-compose.yml + +# Apply to cluster +kubectl apply -f . +``` + +## See Also + +- [Docker Publisher](/publish/docker) - Push images to registries +- [Build Command](/build/cli/build/) - Build Docker images +- [LinuxKit](linuxkit) - VM-based deployment diff --git a/docs/deploy/index.md b/docs/deploy/index.md new file mode 100644 index 0000000..93a746c --- /dev/null +++ b/docs/deploy/index.md @@ -0,0 +1,68 @@ +# Deploy + +Deploy applications to VMs, containers, and cloud infrastructure. + +## Deployment Options + +| Target | Description | Use Case | +|--------|-------------|----------| +| [PHP](php) | Laravel/PHP with FrankenPHP | Web applications, APIs | +| [LinuxKit](linuxkit) | Lightweight immutable VMs | Production servers, edge nodes | +| [Templates](templates) | Pre-configured VM images | Quick deployment, dev environments | +| [Docker](docker) | Container orchestration | Kubernetes, Swarm, ECS | + +## Quick Start + +### Run a Production Server + +```bash +# Build and run from template +core vm run --template server-php --var SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" + +# Or run a pre-built image +core vm run -d --memory 4096 --cpus 4 server.iso +``` + +### Deploy to Docker + +```bash +# Build and push image +core build --type docker --image ghcr.io/myorg/myapp --push + +# Deploy with docker-compose +docker compose up -d +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Build Phase │ +│ core build → Docker images, LinuxKit ISOs, binaries │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Publish Phase │ +│ core ci → GitHub, Docker Hub, GHCR, Homebrew, etc. │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Deploy Phase │ +│ core vm → LinuxKit VMs, templates, orchestration │ +└─────────────────────────────────────────────────────────┘ +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `core vm run` | Run a LinuxKit image or template | +| `core vm ps` | List running VMs | +| `core vm stop` | Stop a VM | +| `core vm logs` | View VM logs | +| `core vm exec` | Execute command in VM | +| `core vm templates` | Manage LinuxKit templates | + +See the [CLI Reference](/build/cli/vm/) for full command documentation. diff --git a/docs/deploy/linuxkit.md b/docs/deploy/linuxkit.md new file mode 100644 index 0000000..cbd4cbd --- /dev/null +++ b/docs/deploy/linuxkit.md @@ -0,0 +1,168 @@ +# LinuxKit VMs + +Deploy applications using lightweight, immutable LinuxKit VMs. VMs run using QEMU or HyperKit depending on your system. + +## Running VMs + +### From Image File + +Run pre-built images in `.iso`, `.qcow2`, `.vmdk`, or `.raw` format: + +```bash +# Run ISO image +core vm run server.iso + +# Run with more resources +core vm run -d --memory 4096 --cpus 4 server.qcow2 + +# Custom SSH port +core vm run --ssh-port 2223 server.iso +``` + +### From Template + +Build and run from a LinuxKit template in one command: + +```bash +# Run template with SSH key +core vm run --template server-php --var SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" + +# Multiple variables +core vm run --template server-php \ + --var SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" \ + --var DOMAIN=example.com +``` + +## Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--template` | Run from a LinuxKit template | - | +| `--var` | Template variable (KEY=VALUE) | - | +| `--name` | VM name | auto | +| `--memory` | Memory in MB | 1024 | +| `--cpus` | CPU count | 1 | +| `--ssh-port` | SSH port for exec | 2222 | +| `-d` | Detached mode (background) | false | + +## Managing VMs + +### List Running VMs + +```bash +# Show running VMs +core vm ps + +# Include stopped VMs +core vm ps -a +``` + +Output: +``` +ID NAME IMAGE STATUS STARTED PID +abc12345 myvm server-php.qcow2 running 5m 12345 +def67890 devbox core-dev.iso stopped 2h - +``` + +### Stop a VM + +```bash +# Full ID +core vm stop abc12345678 + +# Partial ID match +core vm stop abc1 +``` + +### View Logs + +```bash +# View logs +core vm logs abc12345 + +# Follow logs (like tail -f) +core vm logs -f abc12345 +``` + +### Execute Commands + +Run commands in a VM via SSH: + +```bash +# List files +core vm exec abc12345 ls -la + +# Check services +core vm exec abc12345 systemctl status php-fpm + +# Open interactive shell +core vm exec abc12345 /bin/sh +``` + +## Building Images + +Build LinuxKit images with `core build`: + +```bash +# Build ISO from config +core build --type linuxkit --config .core/linuxkit/server.yml + +# Build QCOW2 for QEMU/KVM +core build --type linuxkit --config .core/linuxkit/server.yml --format qcow2-bios + +# Build for multiple platforms +core build --type linuxkit --targets linux/amd64,linux/arm64 +``` + +### Output Formats + +| Format | Description | Use Case | +|--------|-------------|----------| +| `iso-bios` | Bootable ISO | Physical servers, legacy VMs | +| `qcow2-bios` | QEMU/KVM image | Linux hypervisors | +| `raw` | Raw disk image | Cloud providers | +| `vmdk` | VMware image | VMware ESXi | +| `vhd` | Hyper-V image | Windows Server | + +## LinuxKit Configuration + +Example `.core/linuxkit/server.yml`: + +```yaml +kernel: + image: linuxkit/kernel:5.15 + cmdline: "console=tty0" + +init: + - linuxkit/init:v0.8 + - linuxkit/runc:v0.8 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v0.8 + - name: dhcpcd + image: linuxkit/dhcpcd:v0.8 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"] + +services: + - name: sshd + image: linuxkit/sshd:v0.8 + - name: php + image: dunglas/frankenphp:latest + +files: + - path: /etc/ssh/authorized_keys + contents: | + {{ .SSH_KEY }} + - path: /etc/myapp/config.yaml + contents: | + server: + port: 8080 + domain: {{ .DOMAIN }} +``` + +## See Also + +- [Templates](templates) - Pre-configured VM templates +- [LinuxKit Publisher](/publish/linuxkit) - Publish LinuxKit images +- [CLI Reference](/build/cli/vm/) - Full VM command documentation diff --git a/docs/deploy/php.md b/docs/deploy/php.md new file mode 100644 index 0000000..366faf8 --- /dev/null +++ b/docs/deploy/php.md @@ -0,0 +1,311 @@ +# PHP Deployment + +Deploy Laravel/PHP applications using FrankenPHP containers, LinuxKit VMs, or Coolify. + +## Quick Start + +```bash +# Build production image +core php build --name myapp --tag v1.0 + +# Run locally +core php serve --name myapp -d + +# Deploy to Coolify +core php deploy --wait +``` + +## Building Images + +### Docker Image + +Build a production-ready Docker image with FrankenPHP: + +```bash +# Basic build +core php build + +# With custom name and tag +core php build --name myapp --tag v1.0 + +# For specific platform +core php build --name myapp --platform linux/amd64 + +# Without cache +core php build --name myapp --no-cache +``` + +### LinuxKit Image + +Build a bootable VM image: + +```bash +# Build with default template (server-php) +core php build --type linuxkit + +# Build QCOW2 for QEMU/KVM +core php build --type linuxkit --format qcow2 + +# Build ISO for bare metal +core php build --type linuxkit --format iso + +# Custom output path +core php build --type linuxkit --output ./dist/server.qcow2 +``` + +### Build Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--type` | Build type: `docker` or `linuxkit` | docker | +| `--name` | Image name | project directory | +| `--tag` | Image tag | latest | +| `--platform` | Target platform | linux/amd64 | +| `--dockerfile` | Custom Dockerfile path | Dockerfile | +| `--format` | LinuxKit format: qcow2, iso, raw, vmdk | qcow2 | +| `--template` | LinuxKit template | server-php | +| `--no-cache` | Build without cache | false | + +## Running Production Containers + +### Local Testing + +Run a production container locally before deploying: + +```bash +# Run in foreground +core php serve --name myapp + +# Run detached (background) +core php serve --name myapp -d + +# Custom ports +core php serve --name myapp --port 8080 --https-port 8443 + +# With environment file +core php serve --name myapp --env-file .env.production +``` + +### Shell Access + +Debug running containers: + +```bash +# Open shell in container +core php shell + +# Run artisan commands +docker exec -it php artisan migrate:status +``` + +## Deploying to Coolify + +[Coolify](https://coolify.io) is a self-hosted PaaS for deploying applications. + +### Configuration + +Add Coolify credentials to `.env`: + +```env +COOLIFY_URL=https://coolify.example.com +COOLIFY_TOKEN=your-api-token +COOLIFY_APP_ID=production-app-id +COOLIFY_STAGING_APP_ID=staging-app-id +``` + +Or configure in `.core/php.yaml`: + +```yaml +version: 1 + +deploy: + coolify: + server: https://coolify.example.com + project: my-project +``` + +### Deploy Commands + +```bash +# Deploy to production +core php deploy + +# Deploy to staging +core php deploy --staging + +# Force deploy (even if no changes) +core php deploy --force + +# Wait for deployment to complete +core php deploy --wait +``` + +### Check Status + +```bash +# Current deployment status +core php deploy:status + +# Staging status +core php deploy:status --staging + +# Specific deployment +core php deploy:status --id abc123 +``` + +### Rollback + +```bash +# Rollback to previous deployment +core php deploy:rollback + +# Rollback staging +core php deploy:rollback --staging + +# Rollback to specific deployment +core php deploy:rollback --id abc123 + +# Wait for rollback to complete +core php deploy:rollback --wait +``` + +### Deployment History + +```bash +# List recent deployments +core php deploy:list + +# Staging deployments +core php deploy:list --staging + +# Show more +core php deploy:list --limit 20 +``` + +## CI/CD Pipeline + +### GitHub Actions + +```yaml +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Core CLI + run: | + curl -fsSL https://get.host.uk.com | bash + echo "$HOME/.core/bin" >> $GITHUB_PATH + + - name: Build image + run: core php build --name ${{ vars.IMAGE_NAME }} --tag ${{ github.sha }} + + - name: Push to registry + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker push ${{ vars.IMAGE_NAME }}:${{ github.sha }} + + - name: Deploy + env: + COOLIFY_URL: ${{ secrets.COOLIFY_URL }} + COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} + COOLIFY_APP_ID: ${{ secrets.COOLIFY_APP_ID }} + run: core php deploy --wait +``` + +### GitLab CI + +```yaml +deploy: + stage: deploy + image: hostuk/core:latest + script: + - core php build --name $CI_REGISTRY_IMAGE --tag $CI_COMMIT_SHA + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA + - core php deploy --wait + only: + - main +``` + +## Environment Configuration + +### Production .env + +```env +APP_ENV=production +APP_DEBUG=false +APP_URL=https://myapp.com + +# Database +DB_CONNECTION=pgsql +DB_HOST=db.internal +DB_DATABASE=myapp +DB_USERNAME=myapp +DB_PASSWORD=${DB_PASSWORD} + +# Cache & Queue +CACHE_DRIVER=redis +QUEUE_CONNECTION=redis +SESSION_DRIVER=redis +REDIS_HOST=redis.internal + +# FrankenPHP +OCTANE_SERVER=frankenphp +OCTANE_WORKERS=auto +OCTANE_MAX_REQUESTS=1000 +``` + +### Health Checks + +Ensure your app has a health endpoint: + +```php +// routes/web.php +Route::get('/health', fn () => response()->json([ + 'status' => 'ok', + 'timestamp' => now()->toIso8601String(), +])); +``` + +## Deployment Strategies + +### Blue-Green + +```bash +# Build new version +core php build --name myapp --tag v2.0 + +# Deploy to staging +core php deploy --staging --wait + +# Test staging +curl https://staging.myapp.com/health + +# Switch production +core php deploy --wait +``` + +### Canary + +```bash +# Deploy to canary (10% traffic) +COOLIFY_APP_ID=$CANARY_APP_ID core php deploy --wait + +# Monitor metrics, then full rollout +core php deploy --wait +``` + +## See Also + +- [Docker Deployment](docker) - Container orchestration +- [LinuxKit VMs](linuxkit) - VM-based deployment +- [Templates](templates) - Pre-configured VM templates +- [PHP CLI Reference](/build/cli/php/) - Full command documentation diff --git a/docs/deploy/templates.md b/docs/deploy/templates.md new file mode 100644 index 0000000..14e7e58 --- /dev/null +++ b/docs/deploy/templates.md @@ -0,0 +1,220 @@ +# Templates + +Pre-configured LinuxKit templates for common deployment scenarios. + +## Available Templates + +| Template | Description | Platforms | +|----------|-------------|-----------| +| `core-dev` | Full development environment with 100+ tools | linux/amd64, linux/arm64 | +| `server-php` | FrankenPHP production server | linux/amd64, linux/arm64 | +| `edge-node` | Minimal edge deployment | linux/amd64, linux/arm64 | + +## Using Templates + +### List Templates + +```bash +core vm templates list +``` + +Output: +``` +Available Templates: + + core-dev + Full development environment with 100+ tools + Platforms: linux/amd64, linux/arm64 + + server-php + FrankenPHP production server + Platforms: linux/amd64, linux/arm64 + + edge-node + Minimal edge deployment + Platforms: linux/amd64, linux/arm64 +``` + +### Show Template Details + +```bash +core vm templates show server-php +``` + +Output: +``` +Template: server-php + +Description: FrankenPHP production server + +Platforms: + - linux/amd64 + - linux/arm64 + +Formats: + - iso + - qcow2 + +Services: + - sshd + - frankenphp + - php-fpm + +Size: ~800MB +``` + +### Show Template Variables + +```bash +core vm templates vars server-php +``` + +Output: +``` +Variables for server-php: + SSH_KEY (required) SSH public key + DOMAIN (optional) Server domain name + MEMORY (optional) Memory in MB (default: 2048) + CPUS (optional) CPU count (default: 2) +``` + +### Run Template + +```bash +# With required variables +core vm run --template server-php --var SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" + +# With all variables +core vm run --template server-php \ + --var SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" \ + --var DOMAIN=example.com \ + --var MEMORY=4096 +``` + +## Template Locations + +Templates are searched in order: + +1. `.core/linuxkit/` - Project-specific templates +2. `~/.core/templates/` - User templates +3. Built-in templates + +## Creating Templates + +Create a LinuxKit YAML file in `.core/linuxkit/`: + +### Development Template + +`.core/linuxkit/dev.yml`: + +```yaml +kernel: + image: linuxkit/kernel:5.15 + cmdline: "console=tty0" + +init: + - linuxkit/init:v0.8 + - linuxkit/runc:v0.8 + - linuxkit/containerd:v0.8 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v0.8 + - name: dhcpcd + image: linuxkit/dhcpcd:v0.8 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"] + +services: + - name: sshd + image: linuxkit/sshd:v0.8 + - name: docker + image: docker:dind + capabilities: + - all + binds: + - /var/run:/var/run + +files: + - path: /etc/ssh/authorized_keys + contents: | + {{ .SSH_KEY }} +``` + +### Production Template + +`.core/linuxkit/prod.yml`: + +```yaml +kernel: + image: linuxkit/kernel:5.15 + cmdline: "console=tty0 quiet" + +init: + - linuxkit/init:v0.8 + - linuxkit/runc:v0.8 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v0.8 + binds: + - /etc/sysctl.d:/etc/sysctl.d + - name: dhcpcd + image: linuxkit/dhcpcd:v0.8 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"] + +services: + - name: sshd + image: linuxkit/sshd:v0.8 + - name: app + image: myapp:{{ .VERSION }} + capabilities: + - CAP_NET_BIND_SERVICE + binds: + - /var/data:/data + +files: + - path: /etc/ssh/authorized_keys + contents: | + {{ .SSH_KEY }} + - path: /etc/myapp/config.yaml + contents: | + server: + port: 443 + domain: {{ .DOMAIN }} + database: + path: /data/app.db +``` + +Run with: + +```bash +core vm run --template prod \ + --var SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" \ + --var VERSION=1.2.3 \ + --var DOMAIN=example.com +``` + +## Template Variables + +Variables use Go template syntax with double braces: + +```yaml +# Required variable +contents: | + {{ .SSH_KEY }} + +# With default value +contents: | + port: {{ .PORT | default "8080" }} + +# Conditional +{{ if .DEBUG }} + debug: true +{{ end }} +``` + +## See Also + +- [LinuxKit VMs](linuxkit) - Running and managing VMs +- [Build Command](/build/cli/build/) - Building LinuxKit images +- [VM Command](/build/cli/vm/) - Full VM CLI reference diff --git a/docs/discovery/l1-packages-vs-standalone-modules.md b/docs/discovery/l1-packages-vs-standalone-modules.md new file mode 100644 index 0000000..5dc2b84 --- /dev/null +++ b/docs/discovery/l1-packages-vs-standalone-modules.md @@ -0,0 +1,55 @@ +# Discovery: L1 Packages vs Standalone php-* Modules + +**Issue:** #3 +**Date:** 2026-02-21 +**Status:** Complete – findings filed as issues #4, #5, #6, #7 + +## L1 Packages (Boot.php files under src/Core/) + +| Package | Path | Has Standalone? | +|---------|------|----------------| +| Activity | `src/Core/Activity/` | No | +| Bouncer | `src/Core/Bouncer/` | No | +| Bouncer/Gate | `src/Core/Bouncer/Gate/` | No | +| Cdn | `src/Core/Cdn/` | No | +| Config | `src/Core/Config/` | No | +| Console | `src/Core/Console/` | No | +| Front | `src/Core/Front/` | No (root) | +| Front/Admin | `src/Core/Front/Admin/` | Partial – `core/php-admin` extends | +| Front/Api | `src/Core/Front/Api/` | Partial – `core/php-api` extends | +| Front/Cli | `src/Core/Front/Cli/` | No | +| Front/Client | `src/Core/Front/Client/` | No | +| Front/Components | `src/Core/Front/Components/` | No | +| Front/Mcp | `src/Core/Front/Mcp/` | Intentional – `core/php-mcp` fills | +| Front/Stdio | `src/Core/Front/Stdio/` | No | +| Front/Web | `src/Core/Front/Web/` | No | +| Headers | `src/Core/Headers/` | No | +| Helpers | `src/Core/Helpers/` | No | +| Lang | `src/Core/Lang/` | No | +| Mail | `src/Core/Mail/` | No | +| Media | `src/Core/Media/` | No | +| Search | `src/Core/Search/` | No (admin search is separate concern) | +| Seo | `src/Core/Seo/` | No | + +## Standalone Repos + +| Repo | Package | Namespace | Relationship | +|------|---------|-----------|-------------| +| `core/php-tenant` | `host-uk/core-tenant` | `Core\Tenant\` | Extension | +| `core/php-admin` | `host-uk/core-admin` | `Core\Admin\` | Extends Front/Admin | +| `core/php-api` | `host-uk/core-api` | `Core\Api\` | Extends Front/Api | +| `core/php-content` | `host-uk/core-content` | `Core\Mod\Content\` | Extension | +| `core/php-commerce` | `host-uk/core-commerce` | `Core\Mod\Commerce\` | Extension | +| `core/php-agentic` | `host-uk/core-agentic` | `Core\Mod\Agentic\` | Extension | +| `core/php-mcp` | `host-uk/core-mcp` | `Core\Mcp\` | Fills Front/Mcp shell | +| `core/php-developer` | `host-uk/core-developer` | `Core\Developer\` | Extension (also needs core-admin) | +| `core/php-devops` | *(DevOps tooling)* | N/A | Not a PHP module | + +## Overlaps Found + +See issues filed: + +- **#4** `Front/Api` rate limiting vs `core/php-api` `RateLimitApi` middleware – double rate limiting risk +- **#5** `Core\Search` vs `core/php-admin` search subsystem – dual registries +- **#6** `Core\Activity` UI duplicated in `core/php-admin` and `core/php-developer` +- **#7** Summary issue with full analysis diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..76908c5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,126 @@ +--- +layout: home + +hero: + name: Core PHP Framework + text: Modular Monolith for Laravel + tagline: Event-driven architecture with lazy module loading and built-in multi-tenancy + actions: + - theme: brand + text: Get Started + link: /guide/getting-started + - theme: alt + text: View on GitHub + link: https://github.com/host-uk/core-php + +features: + - icon: ⚡️ + title: Event-Driven Modules + details: Modules declare interest in lifecycle events and are only loaded when needed, reducing overhead for unused features. + + - icon: 🔒 + title: Multi-Tenant Isolation + details: Automatic workspace scoping for Eloquent models with strict mode enforcement prevents data leakage. + + - icon: 🎯 + title: Actions Pattern + details: Extract business logic into testable, reusable classes with automatic dependency injection. + + - icon: 📝 + title: Activity Logging + details: Built-in audit trails for model changes with minimal setup using Spatie Activity Log. + + - icon: 🌱 + title: Seeder Auto-Discovery + details: Automatic seeder ordering via priority and dependency attributes eliminates manual registration. + + - icon: 🎨 + title: HLCRF Layouts + details: Data-driven composable layouts with infinite nesting for flexible UI structures. + + - icon: 🔐 + title: Security First + details: Bouncer action gates, request whitelisting, and comprehensive input sanitization. + + - icon: 🚀 + title: Production Ready + details: Battle-tested in production with comprehensive test coverage and security audits. +--- + +## Quick Start + +```bash +# Install via Composer +composer require host-uk/core + +# Create a module +php artisan make:mod Commerce + +# Register lifecycle events +class Boot +{ + public static array $listens = [ + WebRoutesRegistering::class => 'onWebRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } +} +``` + +## Why Core PHP? + +Traditional Laravel applications grow into monoliths with tight coupling and unclear boundaries. Microservices add complexity you may not need. **Core PHP provides a middle ground**: a structured monolith with clear module boundaries, lazy loading, and the ability to extract services later if needed. + +### Key Benefits + +- **Reduced Complexity** - No network overhead, distributed tracing, or service mesh +- **Clear Boundaries** - Modules have explicit dependencies via lifecycle events +- **Performance** - Lazy loading means unused modules aren't loaded +- **Flexibility** - Start monolithic, extract services when it makes sense +- **Type Safety** - Full IDE support with no RPC serialization + +## Packages + +
+ +### [Core](/packages/core) +Event-driven architecture, module system, actions pattern, and multi-tenancy. + +### [Admin](/packages/admin) +Livewire-powered admin panel with global search and service management. + +### [API](/packages/api) +REST API with OpenAPI docs, rate limiting, webhook signing, and secure keys. + +### [MCP](/packages/mcp) +Model Context Protocol tools for AI integrations with analytics and security. + +
+ +## Community + +- **GitHub Discussions** - Ask questions and share ideas +- **Issue Tracker** - Report bugs and request features +- **Contributing** - See our [contributing guide](/contributing) + + diff --git a/docs/packages/admin/authorization.md b/docs/packages/admin/authorization.md new file mode 100644 index 0000000..a10f19b --- /dev/null +++ b/docs/packages/admin/authorization.md @@ -0,0 +1,559 @@ +# Authorization + +Integration with Laravel's Gate and Policy system for fine-grained authorization in admin panels. + +## Form Component Authorization + +All form components support authorization props: + +```blade + + Publish Post + +``` + +### Authorization Props + +**`can` - Single ability:** + +```blade + + Delete + + +{{-- Only shown if user can delete the post --}} +``` + +**`cannot` - Inverse check:** + +```blade + + +{{-- Disabled if user cannot publish --}} +``` + +**`canAny` - Multiple abilities (OR):** + +```blade + + Edit Post + + +{{-- Shown if user can either edit OR update --}} +``` + +## Policy Integration + +### Defining Policies + +```php +workspace_id === $post->workspace_id; + } + + public function create(User $user): bool + { + return $user->hasPermission('posts.create'); + } + + 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') + && $user->workspace_id === $post->workspace_id; + } + + public function publish(User $user, Post $post): bool + { + return $user->hasPermission('posts.publish') + && $post->status !== 'archived'; + } +} +``` + +### Registering Policies + +```php +use Illuminate\Support\Facades\Gate; +use Mod\Blog\Models\Post; +use Mod\Blog\Policies\PostPolicy; + +// In AuthServiceProvider or module Boot class +Gate::policy(Post::class, PostPolicy::class); +``` + +## Action Gate + +Use the Action Gate system for route-level authorization: + +### Defining Actions + +```php +middleware(['auth', ActionGateMiddleware::class]); + +// Protect route group +Route::middleware(['auth', ActionGateMiddleware::class]) + ->group(function () { + Route::post('/posts', [PostController::class, 'store']); + Route::post('/posts/{post}/publish', [PostController::class, 'publish']); + }); +``` + +### Checking Permissions + +```php +use Core\Bouncer\Gate\ActionGateService; + +$gate = app(ActionGateService::class); + +// Check if user can perform action +if ($gate->allows('posts.create', auth()->user())) { + // User has permission +} + +// Check with additional context +if ($gate->allows('posts.publish', auth()->user(), $post)) { + // User can publish this specific post +} + +// Get all user permissions +$permissions = $gate->getUserPermissions(auth()->user()); +``` + +## Admin Menu Authorization + +Restrict menu items by permission: + +```php +use Core\Front\Admin\Support\MenuItemBuilder; + +MenuItemBuilder::create('Posts') + ->route('admin.posts.index') + ->icon('heroicon-o-document-text') + ->can('posts.view') // Only shown if user can view posts + ->badge(fn () => Post::pending()->count()) + ->children([ + MenuItemBuilder::create('All Posts') + ->route('admin.posts.index'), + + MenuItemBuilder::create('Create Post') + ->route('admin.posts.create') + ->can('posts.create'), // Nested permission check + + MenuItemBuilder::create('Categories') + ->route('admin.categories.index') + ->canAny(['categories.view', 'categories.edit']), + ]); +``` + +## Livewire Modal Authorization + +Protect Livewire modals with authorization checks: + +```php +authorize('update', $post); + + $this->post = $post; + } + + public function save() + { + // Authorize action + $this->authorize('update', $this->post); + + $this->post->save(); + + $this->dispatch('post-updated'); + } + + public function publish() + { + // Custom authorization + $this->authorize('publish', $this->post); + + $this->post->update(['status' => 'published']); + } +} +``` + +## Workspace Scoping + +Automatic workspace isolation with policies: + +```php +class PostPolicy +{ + public function viewAny(User $user): bool + { + // User can view posts in their workspace + return true; + } + + public function view(User $user, Post $post): bool + { + // Enforce workspace boundary + return $user->workspace_id === $post->workspace_id; + } + + public function update(User $user, Post $post): bool + { + // Workspace check + additional authorization + return $user->workspace_id === $post->workspace_id + && ($user->id === $post->author_id || $user->hasRole('editor')); + } +} +``` + +## Role-Based Authorization + +### Defining Roles + +```php +use Mod\Tenant\Models\User; + +// Assign role +$user->assignRole('editor'); + +// Check role +if ($user->hasRole('admin')) { + // User is admin +} + +// Check any role +if ($user->hasAnyRole(['editor', 'author'])) { + // User has at least one role +} + +// Check all roles +if ($user->hasAllRoles(['editor', 'reviewer'])) { + // User has both roles +} +``` + +### Policy with Roles + +```php +class PostPolicy +{ + public function update(User $user, Post $post): bool + { + return $user->hasRole('admin') + || ($user->hasRole('editor') && $user->workspace_id === $post->workspace_id) + || ($user->hasRole('author') && $user->id === $post->author_id); + } + + public function delete(User $user, Post $post): bool + { + // Only admins can delete + return $user->hasRole('admin'); + } +} +``` + +## Permission-Based Authorization + +### Defining Permissions + +```php +// Grant permission +$user->givePermission('posts.create'); +$user->givePermission('posts.publish'); + +// Check permission +if ($user->hasPermission('posts.publish')) { + // User can publish +} + +// Check multiple permissions +if ($user->hasAllPermissions(['posts.create', 'posts.publish'])) { + // User has all permissions +} + +// Check any permission +if ($user->hasAnyPermission(['posts.edit', 'posts.delete'])) { + // User has at least one permission +} +``` + +### Policy with Permissions + +```php +class PostPolicy +{ + public function create(User $user): bool + { + return $user->hasPermission('posts.create'); + } + + public function publish(User $user, Post $post): bool + { + return $user->hasPermission('posts.publish') + && $post->status === 'draft'; + } +} +``` + +## Conditional Rendering + +### Blade Directives + +```blade +@can('create', App\Models\Post::class) + Create Post +@endcan + +@cannot('delete', $post) +

You cannot delete this post

+@endcannot + +@canany(['edit', 'update'], $post) + Edit +@endcanany +``` + +### Component Visibility + +```blade + + Publish + + +{{-- Automatically hidden if user cannot publish --}} +``` + +### Form Field Disabling + +```blade + + +{{-- Disabled if user cannot edit slug --}} +``` + +## Authorization Middleware + +### Global Middleware + +```php +// app/Http/Kernel.php +protected $middlewareGroups = [ + 'web' => [ + // ... + \Core\Bouncer\Gate\ActionGateMiddleware::class, + ], +]; +``` + +### Route Middleware + +```php +// Require authentication +Route::middleware(['auth'])->group(function () { + Route::get('/admin', [AdminController::class, 'index']); +}); + +// Require specific ability +Route::middleware(['can:create,App\Models\Post'])->group(function () { + Route::get('/posts/create', [PostController::class, 'create']); +}); +``` + +## Testing Authorization + +```php +use Tests\TestCase; +use Mod\Blog\Models\Post; +use Mod\Tenant\Models\User; + +class AuthorizationTest extends TestCase +{ + public function test_user_can_view_own_posts(): void + { + $user = User::factory()->create(); + $post = Post::factory()->create(['author_id' => $user->id]); + + $this->assertTrue($user->can('view', $post)); + } + + public function test_user_cannot_delete_others_posts(): void + { + $user = User::factory()->create(); + $post = Post::factory()->create(); // Different author + + $this->assertFalse($user->can('delete', $post)); + } + + public function test_admin_can_delete_any_post(): void + { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $post = Post::factory()->create(); + + $this->assertTrue($admin->can('delete', $post)); + } + + public function test_workspace_isolation(): void + { + $user1 = User::factory()->create(['workspace_id' => 1]); + $user2 = User::factory()->create(['workspace_id' => 2]); + + $post = Post::factory()->create(['workspace_id' => 1]); + + $this->assertTrue($user1->can('view', $post)); + $this->assertFalse($user2->can('view', $post)); + } +} +``` + +## Best Practices + +### 1. Always Check Workspace Boundaries + +```php +// ✅ Good - workspace check +public function view(User $user, Post $post): bool +{ + return $user->workspace_id === $post->workspace_id; +} + +// ❌ Bad - no workspace check +public function view(User $user, Post $post): bool +{ + return true; // Data leak! +} +``` + +### 2. Use Policies Over Gates + +```php +// ✅ Good - policy +$this->authorize('update', $post); + +// ❌ Bad - manual check +if (auth()->id() !== $post->author_id) { + abort(403); +} +``` + +### 3. Authorize Early + +```php +// ✅ Good - authorize in mount +public function mount(Post $post) +{ + $this->authorize('update', $post); + $this->post = $post; +} + +// ❌ Bad - authorize in action +public function save() +{ + $this->authorize('update', $this->post); // Too late! + $this->post->save(); +} +``` + +### 4. Use Authorization Props + +```blade +{{-- ✅ Good - declarative authorization --}} + + Delete + + +{{-- ❌ Bad - manual check --}} +@if(auth()->user()->can('delete', $post)) + Delete +@endif +``` + +## Learn More + +- [Form Components →](/packages/admin/forms) +- [Admin Menus →](/packages/admin/menus) +- [Multi-Tenancy →](/packages/core/tenancy) diff --git a/docs/packages/admin/components-reference.md b/docs/packages/admin/components-reference.md new file mode 100644 index 0000000..1daaeb5 --- /dev/null +++ b/docs/packages/admin/components-reference.md @@ -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 + +``` + +### 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 --}} + +``` + +### Type Variants + +```blade +{{-- Text input --}} + + +{{-- Email input --}} + + +{{-- Password input --}} + + +{{-- Number input --}} + + +{{-- Date input --}} + + +{{-- URL input --}} + +``` + +### Instant Save Mode + +```blade +{{-- Saves with 500ms debounce --}} + +``` + +### 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 + +``` + +### 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 + +``` + +### With Character Limit + +```blade + +``` + +--- + +## Select + +Dropdown select with authorization support. + +### Basic Usage + +```blade + + Draft + Published + Archived + +``` + +### 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 + + @foreach($categories as $category) + + {{ $category->name }} + + @endforeach + +``` + +### With Placeholder + +```blade + + United States + United Kingdom + Canada + +``` + +### Multiple Selection + +```blade + + @foreach($tags as $tag) + + {{ $tag->name }} + + @endforeach + +``` + +--- + +## Checkbox + +Single checkbox with authorization support. + +### Basic Usage + +```blade + +``` + +### 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 + +``` + +### With Helper Text + +```blade + +``` + +### Checkbox Group + +```blade +
+ Notifications + + + + + + +
+``` + +--- + +## Toggle + +Switch-style toggle with authorization support. + +### Basic Usage + +```blade + +``` + +### 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 + +``` + +### Instant Save + +```blade +{{-- Toggle that saves immediately --}} + +``` + +### With Helper + +```blade + +``` + +--- + +## Button + +Action button with variants and authorization support. + +### Basic Usage + +```blade + + Save Changes + +``` + +### 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) --}} +Primary + +{{-- Secondary --}} +Secondary + +{{-- Danger --}} +Delete + +{{-- Ghost --}} +Cancel +``` + +### Authorization Example + +```blade +{{-- Button disabled if user cannot delete --}} + + Delete Post + +``` + +### With Loading State + +```blade + + Save + Saving... + +``` + +### As Link + +```blade + + Cancel + +``` + +--- + +## 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 + +``` + +**Multiple Components with Same Auth:** +```blade +@php $canEdit = auth()->user()?->can('update', $post); @endphp + + + +Save +``` + +**Combining with Blade Directives:** +```blade +@can('update', $post) + + Save +@else +

You do not have permission to edit this post.

+@endcan +``` + +### Defining Policies + +```php +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 + + +{{-- Renders as: --}} + + Email Address + + +``` + +### Error Announcements + +Validation errors are linked to inputs and announced to screen readers: + +```blade +{{-- Component renders error with aria-describedby link --}} + + +{{-- 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 --}} + +``` + +### 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 +
+ {{-- Title --}} + + + {{-- Slug with instant save --}} + + + {{-- Content --}} + + + {{-- Category --}} + + @foreach($categories as $category) + + {{ $category->name }} + + @endforeach + + + {{-- Status --}} + + Draft + Published + Archived + + + {{-- Featured toggle --}} + + + {{-- Newsletter checkbox --}} + + + {{-- Actions --}} +
+ + Save Changes + + + + Cancel + + + @can('delete', $post) + + Delete + + @endcan +
+ +``` + +## 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) diff --git a/docs/packages/admin/components.md b/docs/packages/admin/components.md new file mode 100644 index 0000000..7de1efe --- /dev/null +++ b/docs/packages/admin/components.md @@ -0,0 +1,623 @@ +# Admin Components + +Reusable UI components for building admin panels: cards, tables, stat widgets, and more. + +## Cards + +### Basic Card + +```blade + + +

Recent Posts

+
+ +

Card content goes here...

+ + + View All + +
+``` + +### Card with Actions + +```blade + + +

Post Statistics

+ + + Refresh + + +
+ +
+ {{-- Statistics content --}} +
+
+``` + +### Card Grid + +Display cards in responsive grid: + +```blade + + +

Total Posts

+

1,234

+
+ + +

Published

+

856

+
+ + +

Drafts

+

378

+
+
+``` + +## Stat Widgets + +### Simple Stat + +```blade + +``` + +### Stat with Trend + +```blade + +``` + +**Trend Indicators:** +- Positive number: green up arrow +- Negative number: red down arrow +- Zero: neutral indicator + +### Stat with Chart + +```blade + +``` + +**Sparkline Data:** + +```php +public function getSparklineData() +{ + return [ + 120, 145, 132, 158, 170, 165, 180, 195, 185, 200 + ]; +} +``` + +### Stat Grid + +```blade +
+ + + + + + + +
+``` + +## Tables + +### Basic Table + +```blade + + + Title + Author + Status + Actions + + + @foreach($posts as $post) + + {{ $post->title }} + {{ $post->author->name }} + + + {{ $post->status }} + + + + + Edit + + + + @endforeach + +``` + +### Sortable Table + +```blade + + + + Title + + + Created + + + + {{-- Table rows --}} + +``` + +**Livewire Component:** + +```php +class PostsTable extends Component +{ + public $sortField = 'created_at'; + public $sortDirection = 'desc'; + + public function sortBy($field) + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + } + + public function render() + { + $posts = Post::orderBy($this->sortField, $this->sortDirection) + ->paginate(20); + + return view('livewire.posts-table', compact('posts')); + } +} +``` + +### Table with Bulk Actions + +```blade + + + + + + Title + Actions + + + @foreach($posts as $post) + + + + + {{ $post->title }} + ... + + @endforeach + + +@if(count($selected) > 0) +
+

{{ count($selected) }} selected

+ Publish + Delete +
+@endif +``` + +## Badges + +### Status Badges + +```blade +Published +Draft +Archived +Scheduled +Pending +``` + +### Badge with Dot + +```blade + + Active + +``` + +### Badge with Icon + +```blade + + + ... + + Verified + +``` + +### Removable Badge + +```blade + + {{ $tag->name }} + +``` + +## Alerts + +### Basic Alert + +```blade + + Post published successfully! + + + + Failed to save post. Please try again. + + + + This post has not been reviewed yet. + + + + You have 3 draft posts. + +``` + +### Dismissible Alert + +```blade + + Post published successfully! + +``` + +### Alert with Title + +```blade + + + Pending Review + + This post requires approval before it can be published. + +``` + +## Empty States + +### Basic Empty State + +```blade + + + ... + + + + No posts yet + + + + Get started by creating your first blog post. + + + + + Create Post + + + +``` + +### Search Empty State + +```blade +@if($posts->isEmpty() && $search) + + + No results found + + + + No posts match your search for "{{ $search }}". + + + + + Clear Search + + + +@endif +``` + +## Loading States + +### Skeleton Loaders + +```blade + + + +``` + +### Loading Spinner + +```blade +
+ +
+ +
+ {{-- Content --}} +
+``` + +### Loading Overlay + +```blade +
+ {{-- Content becomes translucent while loading --}} +
+ +
+ +
+``` + +## Pagination + +```blade + + {{-- Table content --}} + + +{{ $posts->links('admin::pagination') }} +``` + +**Custom Pagination:** + +```blade + +``` + +## Modals (See Modals Documentation) + +See [Livewire Modals →](/packages/admin/modals) for full modal documentation. + +## Dropdowns + +### Basic Dropdown + +```blade + + + + Actions + + + + + Edit + + + + Duplicate + + + + + + Delete + + +``` + +### Dropdown with Icons + +```blade + + + + + + + + ... + + Edit Post + + + + + ... + + View + + +``` + +## Tabs + +```blade + + + {{-- General settings --}} + + + + {{-- SEO settings --}} + + + + {{-- Advanced settings --}} + + +``` + +## Best Practices + +### 1. Use Semantic Components + +```blade +{{-- ✅ Good - semantic component --}} + + +{{-- ❌ Bad - manual markup --}} +
+

Revenue

+ {{ $revenue }} +
+``` + +### 2. Consistent Colors + +```blade +{{-- ✅ Good - use color props --}} +Active +Inactive + +{{-- ❌ Bad - custom classes --}} +Active +``` + +### 3. Loading States + +```blade +{{-- ✅ Good - show loading state --}} +
+ +
+ +{{-- ❌ Bad - no feedback --}} + +``` + +### 4. Empty States + +```blade +{{-- ✅ Good - helpful empty state --}} +@if($posts->isEmpty()) + + + + Create First Post + + + +@endif + +{{-- ❌ Bad - no guidance --}} +@if($posts->isEmpty()) +

No posts

+@endif +``` + +## Testing Components + +```php +use Tests\TestCase; + +class ComponentsTest extends TestCase +{ + public function test_stat_widget_renders(): void + { + $view = $this->blade(''); + + $view->assertSee('Users'); + $view->assertSee('100'); + } + + public function test_badge_renders_with_color(): void + { + $view = $this->blade('Active'); + + $view->assertSee('Active'); + $view->assertSeeInOrder(['class', 'green']); + } +} +``` + +## Learn More + +- [Form Components →](/packages/admin/forms) +- [Livewire Modals →](/packages/admin/modals) +- [Authorization →](/packages/admin/authorization) diff --git a/docs/packages/admin/creating-admin-panels.md b/docs/packages/admin/creating-admin-panels.md new file mode 100644 index 0000000..9fcf1d4 --- /dev/null +++ b/docs/packages/admin/creating-admin-panels.md @@ -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 + '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 + '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 --}} + + +
+

+ {{ $post ? 'Edit Post' : 'Create Post' }} +

+ + + + +
+
+ + +
+ + + + + + Draft + Published + Archived + + +
+ + {{ $post ? 'Update' : 'Create' }} Post + + + + Cancel + +
+ +
+ + +
+

Publishing Tips

+
    +
  • Use descriptive titles
  • +
  • Save as draft first
  • +
  • Preview before publishing
  • +
+
+
+
+``` + +### Modal with Authorization + +```php +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 + '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 --}} + + Save Changes + + +{{-- Input disabled if user cannot update --}} + + +{{-- Textarea with authorization --}} + + +{{-- Select with authorization --}} + + Draft + Published + + +{{-- Toggle with authorization --}} + +``` + +### Blade Conditional Rendering + +```blade +{{-- Show only if user can create --}} +@can('create', App\Models\Post::class) + New Post +@endcan + +{{-- Show if user can edit OR delete --}} +@canany(['update', 'delete'], $post) +
+ @can('update', $post) + Edit + @endcan + + @can('delete', $post) + + @endcan +
+@endcanany + +{{-- Show message if cannot edit --}} +@cannot('update', $post) +

You cannot edit this post.

+@endcannot +``` + +### Creating Policies + +```php +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 + '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 +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 +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 + + +
+

Blog Posts

+ + @can('create', \Mod\Blog\Models\Post::class) + + + New Post + + @endcan +
+
+ + + {{-- Filters --}} +
+ + + + All Statuses + Draft + Published + +
+ + {{-- Posts table --}} +
+ + + + + + + + + + + @forelse($this->posts as $post) + + + + + + + @empty + + + + @endforelse + +
TitleStatusDateActions
{{ $post->title }} + + {{ ucfirst($post->status) }} + + {{ $post->created_at->format('M d, Y') }} + @can('update', $post) + + Edit + + @endcan + + @can('delete', $post) + + @endcan +
+ No posts found. +
+
+ + {{-- Pagination --}} +
+ {{ $this->posts->links() }} +
+
+
+``` + +## 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 --}} + + Save + + +{{-- Not manual checks --}} +@if(auth()->user()->can('update', $post)) + +@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 --}} + + ... + ... + +``` + +## 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) diff --git a/docs/packages/admin/forms.md b/docs/packages/admin/forms.md new file mode 100644 index 0000000..09a53f4 --- /dev/null +++ b/docs/packages/admin/forms.md @@ -0,0 +1,627 @@ +# Form Components + +The Admin package provides a comprehensive set of form components with consistent styling, validation, and authorization support. + +## Overview + +All form components: +- Follow consistent design patterns +- Support Laravel validation +- Include accessibility attributes (ARIA) +- Work with Livewire +- Support authorization props + +## Form Group + +Wrapper component for labels, inputs, and validation errors: + +```blade + + + +``` + +**Props:** +- `label` (string) - Field label +- `name` (string) - Field name for validation errors +- `required` (bool) - Show required indicator +- `help` (string) - Help text below field +- `error` (string) - Manual error message + +## Input + +Text input with various types: + +```blade +{{-- Text input --}} + + +{{-- Email input --}} + + +{{-- Password input --}} + + +{{-- Number input --}} + + +{{-- Date input --}} + +``` + +**Props:** +- `name` (string, required) - Input name +- `label` (string) - Label text +- `type` (string) - Input type (text, email, password, number, date, etc.) +- `value` (string) - Input value +- `placeholder` (string) - Placeholder text +- `required` (bool) - Required field +- `disabled` (bool) - Disabled state +- `readonly` (bool) - Read-only state +- `min` / `max` (number) - Min/max for number inputs + +## Textarea + +Multi-line text input: + +```blade + + +{{-- With character counter --}} + +``` + +**Props:** +- `name` (string, required) - Textarea name +- `label` (string) - Label text +- `rows` (number) - Number of rows (default: 5) +- `cols` (number) - Number of columns +- `placeholder` (string) - Placeholder text +- `maxlength` (number) - Maximum character length +- `show-counter` (bool) - Show character counter +- `required` (bool) - Required field + +## Select + +Dropdown select: + +```blade +{{-- Simple select --}} + + +{{-- With placeholder --}} + + +{{-- Multiple select --}} + + +{{-- Grouped options --}} + +``` + +**Props:** +- `name` (string, required) - Select name +- `label` (string) - Label text +- `options` (array, required) - Options array +- `value` (mixed) - Selected value(s) +- `placeholder` (string) - Placeholder option +- `multiple` (bool) - Allow multiple selections +- `required` (bool) - Required field +- `disabled` (bool) - Disabled state + +## Checkbox + +Single checkbox: + +```blade + + +{{-- With description --}} + + +{{-- Group of checkboxes --}} +
+ Permissions + + + + +
+``` + +**Props:** +- `name` (string, required) - Checkbox name +- `label` (string) - Label text +- `value` (string) - Checkbox value +- `checked` (bool) - Checked state +- `description` (string) - Help text below checkbox +- `disabled` (bool) - Disabled state + +## Toggle + +Switch-style toggle: + +```blade + + +{{-- With colors --}} + +``` + +**Props:** +- `name` (string, required) - Toggle name +- `label` (string) - Label text +- `checked` (bool) - Checked state +- `description` (string) - Help text +- `color` (string) - Toggle color (green, blue, red) +- `disabled` (bool) - Disabled state + +## Button + +Action buttons with variants: + +```blade +{{-- Primary button --}} + + Save Changes + + +{{-- Secondary button --}} + + Cancel + + +{{-- Danger button --}} + + Delete Post + + +{{-- Ghost button --}} + + Reset + + +{{-- Icon button --}} + + + + +{{-- Loading state --}} + + Save + Saving... + +``` + +**Props:** +- `type` (string) - Button type (button, submit, reset) +- `variant` (string) - Style variant (primary, secondary, danger, ghost, icon) +- `href` (string) - Link URL (renders as ``) +- `loading` (bool) - Show loading state +- `disabled` (bool) - Disabled state +- `size` (string) - Size (sm, md, lg) + +## Authorization Props + +All form components support authorization attributes: + +```blade + + Create Post + + + + + + Delete + +``` + +**Authorization Props:** +- `can` (string) - Gate/policy check +- `can-arguments` (array) - Arguments for gate check +- `cannot` (string) - Inverse of `can` +- `hidden-unless` (string) - Hide element unless authorized +- `readonly-unless` (string) - Make readonly unless authorized +- `disabled-unless` (string) - Disable unless authorized + +[Learn more about Authorization →](/packages/admin/authorization) + +## Livewire Integration + +All components work seamlessly with Livewire: + +```blade +
+ + + + + + + + Save Post + + +``` + +### Real-Time Validation + +```blade + + +@error('slug') +

{{ $message }}

+@enderror +``` + +### Debounced Input + +```blade + +``` + +## Validation + +Components automatically show validation errors: + +```blade +{{-- Controller validation --}} +$request->validate([ + 'title' => 'required|max:255', + 'content' => 'required', + 'status' => 'required|in:draft,published', +]); + +{{-- Blade template --}} + + + +{{-- Validation errors automatically displayed --}} +``` + +### Custom Error Messages + +```blade + + + +``` + +## Complete Form Example + +```blade +
+ @csrf + +
+ {{-- Title --}} + + + + + {{-- Slug --}} + + + + + {{-- Content --}} + + + + + {{-- Status --}} + + + + + {{-- Category --}} + + + + + {{-- Options --}} +
+ + + +
+ + {{-- Actions --}} +
+ + Save Post + + + + Cancel + + + + Delete + +
+
+
+``` + +## Styling + +Components use Tailwind CSS and can be customized: + +```blade + +``` + +### Custom Wrapper Classes + +```blade + + + +``` + +## Best Practices + +### 1. Always Use Form Groups + +```blade +{{-- ✅ Good - wrapped in form-group --}} + + + + +{{-- ❌ Bad - no form-group --}} + +``` + +### 2. Use Old Values + +```blade +{{-- ✅ Good - preserves input on validation errors --}} + + +{{-- ❌ Bad - loses input on validation errors --}} + +``` + +### 3. Provide Helpful Placeholders + +```blade +{{-- ✅ Good - clear placeholder --}} + + +{{-- ❌ Bad - vague placeholder --}} + +``` + +### 4. Use Authorization Props + +```blade +{{-- ✅ Good - respects permissions --}} + + Delete + +``` + +## Learn More + +- [Livewire Modals →](/packages/admin/modals) +- [Authorization →](/packages/admin/authorization) +- [HLCRF Layouts →](/packages/admin/hlcrf) diff --git a/docs/packages/admin/hlcrf-deep-dive.md b/docs/packages/admin/hlcrf-deep-dive.md new file mode 100644 index 0000000..f153cbe --- /dev/null +++ b/docs/packages/admin/hlcrf-deep-dive.md @@ -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('
Simple content without chrome
'); + +echo $layout->render(); +``` + +**Output:** +```html +
+
+
+
+
Simple content without chrome
+
+
+
+
+``` + +### Header + Content + Footer (HCF) + +Standard page layout: + +```php +$layout = Layout::make('HCF') + ->h('') + ->c('
Page Content
') + ->f('
Copyright 2026
'); +``` + +### Left + Content (LC) + +Application with navigation sidebar: + +```php +$layout = Layout::make('LC') + ->l('') + ->c('
App Content
'); +``` + +### Three-Column (LCR) + +Full three-column layout: + +```php +$layout = Layout::make('LCR') + ->l('') + ->c('
Content
') + ->r(''); +``` + +### Full Admin (HLCRF) + +Complete admin panel: + +```php +$layout = Layout::make('HLCRF') + ->h('
Admin Header
') + ->l('') + ->c('
Dashboard
') + ->r('') + ->f('
Status Bar
'); +``` + +## 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 +
+
+
First header element
+
Second header element
+
+
+
First content element
+
+
+``` + +**Nested layout:** +```html +
+
+
+ +
+ +
+
Nested content
+
+
+
+
+
+``` + +### 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 + + Navigation + Content + Widgets + +``` + +**Behavior:** +- **Mobile (< 768px):** Left -> Content -> Right (vertical) +- **Tablet (768px-1024px):** Left | Content (two columns) +- **Desktop (> 1024px):** Left | Content | Right (three columns) + +### Collapsible Sidebars + +```blade + + + +``` + +### Hidden Regions on Mobile + +```blade + +``` + +### Flexible Width Distribution + +```blade + + + Fixed-width sidebar + + + + Flexible content + + + + Percentage-width sidebar + + +``` + +### Responsive Grid Inside Content + +```blade + +
+ + + +
+
+``` + +## 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( + '' + ) + ->l( + '
' + ) + ->c( + // Nested layout inside content + Layout::make('HCR') + ->h('
+

Dashboard

+ +
') + ->c('
+
+
Stat 1
+
Stat 2
+
Stat 3
+
+
+

Recent Activity

+ ...
+
+
') + ->r('') + ) + ->f( + '
+ Version 1.0.0 | Last sync: 5 minutes ago +
' + ); + +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('
+ +
Search | Cart | Account
+
') + ->c( + Layout::make('LCR') + ->l('
+
+ Product +
+
+ + +
+
') + ->c( + // Empty - using left/right only + ) + ->r('
+

Product Name

+

$99.99

+

Product description...

+ +
+ + +
+ +
+

Shipping Info

+

Free delivery over $50

+
+
'), + // Reviews section + Layout::make('CR') + ->c('
+

Customer Reviews

+
+
Review 1...
+
Review 2...
+
+
') + ->r('') + ) + ->f('
+
+
About Us
+
Customer Service
+
Policies
+
Newsletter
+
+
'); +``` + +### Multi-Panel Settings Page + +Settings page with multiple nested panels: + +```php +$settings = Layout::make('HLC') + ->h('
+

Account Settings

+
') + ->l('') + ->c( + // Profile section + Layout::make('HCF') + ->h('
+

Profile Information

+

Update your account details

+
') + ->c('
+
+ + +
+
+ + +
+
+ + +
+
') + ->f('
+ + +
') + ); +``` + +### Documentation Site + +Documentation layout with table of contents: + +```php +$docs = Layout::make('HLCRF') + ->h('
+
+
+ + +
+
+ + GitHub +
+
+
') + ->l('') + ->c('
+

Introduction

+

Welcome to the documentation...

+ +

Key Features

+
    +
  • Feature 1
  • +
  • Feature 2
  • +
  • Feature 3
  • +
+ +

Next Steps

+

Continue to the installation guide...

+
') + ->r('') + ->f(''); +``` + +### Email Client Interface + +Complex email client with multiple nested panels: + +```php +$email = Layout::make('HLCR') + ->h('
+
+ + +
+
+ +
+
+ +
JD
+
+
') + ->l('') + ->c( + Layout::make('LC') + ->l('
+
+ +
+
+
+
+ John Smith + 10:30 AM +
+
Meeting Tomorrow
+
Hi, just wanted to confirm...
+
+
+
+ Jane Doe + Yesterday +
+
Project Update
+
Here is the latest update...
+
+
+
') + ->c('
+
+ + + + + + +
+
+
+

Meeting Tomorrow

+
+
JS
+
+
John Smith <john@example.com>
+
to me
+
+
Jan 15, 2026, 10:30 AM
+
+
+
+

Hi,

+

Just wanted to confirm our meeting tomorrow at 2pm.

+

Best regards,
John

+
+
+
') + ) + ->r(''); +``` + +## Performance Considerations + +### Lazy Content Loading + +For large layouts, defer non-critical content: + +```php +$layout = Layout::make('LCR') + ->l('') + ->c('
+
Loading...
+
@livewire("content-panel")
+
') + ->r(fn () => view('widgets.sidebar')); // Closure defers evaluation +``` + +### Conditional Region Rendering + +Only render regions when needed: + +```php +$layout = Layout::make('LCR'); + +$layout->l(''); +$layout->c('
Content
'); + +// Conditionally add right sidebar +if ($user->hasFeature('widgets')) { + $layout->r(''); +} +``` + +### 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('') +->l('') +->c('
Page content
') +->r('') +->f('
Site footer
') + +// Bad - misuse of regions +->h('') // 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(''; + + return $html; + } +} diff --git a/src/Core/Front/Components/Text.php b/src/Core/Front/Components/Text.php new file mode 100644 index 0000000..7f02214 --- /dev/null +++ b/src/Core/Front/Components/Text.php @@ -0,0 +1,155 @@ +muted() + * Text::make()->content('Paragraph text')->p() + */ +class Text extends Component +{ + protected string $content = ''; + + protected string $tag = 'span'; + + protected string $variant = 'default'; + + public function __construct(string $content = '') + { + $this->content = $content; + } + + /** + * Create with initial content. + */ + public static function make(string $content = ''): static + { + return new static($content); + } + + /** + * Set the text content. + */ + public function content(string $content): static + { + $this->content = $content; + + return $this; + } + + /** + * Render as a paragraph. + */ + public function p(): static + { + $this->tag = 'p'; + + return $this; + } + + /** + * Render as a span. + */ + public function span(): static + { + $this->tag = 'span'; + + return $this; + } + + /** + * Render as a div. + */ + public function div(): static + { + $this->tag = 'div'; + + return $this; + } + + /** + * Default text styling. + */ + public function default(): static + { + $this->variant = 'default'; + + return $this; + } + + /** + * Muted/subtle text. + */ + public function muted(): static + { + $this->variant = 'muted'; + + return $this; + } + + /** + * Success text (green). + */ + public function success(): static + { + $this->variant = 'success'; + + return $this; + } + + /** + * Warning text (amber). + */ + public function warning(): static + { + $this->variant = 'warning'; + + return $this; + } + + /** + * Error text (red). + */ + public function error(): static + { + $this->variant = 'error'; + + return $this; + } + + /** + * Get variant CSS classes. + */ + protected function variantClasses(): array + { + return match ($this->variant) { + 'muted' => ['text-zinc-500', 'dark:text-zinc-400'], + 'success' => ['text-green-600', 'dark:text-green-400'], + 'warning' => ['text-amber-600', 'dark:text-amber-400'], + 'error' => ['text-red-600', 'dark:text-red-400'], + default => ['text-zinc-900', 'dark:text-zinc-100'], + }; + } + + /** + * Render the text to HTML. + */ + public function render(): string + { + $attrs = $this->buildAttributes($this->variantClasses()); + + return '<'.$this->tag.$attrs.'>'.e($this->content).'tag.'>'; + } +} diff --git a/src/Core/Front/Components/View/Blade/accordion.blade.php b/src/Core/Front/Components/View/Blade/accordion.blade.php new file mode 100644 index 0000000..4228d05 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/accordion.blade.php @@ -0,0 +1,8 @@ +@props([ + 'transition' => null, // Enable transition animations + 'exclusive' => null, // Only one item open at a time +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/accordion/content.blade.php b/src/Core/Front/Components/View/Blade/accordion/content.blade.php new file mode 100644 index 0000000..8c25bdf --- /dev/null +++ b/src/Core/Front/Components/View/Blade/accordion/content.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/accordion/heading.blade.php b/src/Core/Front/Components/View/Blade/accordion/heading.blade.php new file mode 100644 index 0000000..f6ee0ec --- /dev/null +++ b/src/Core/Front/Components/View/Blade/accordion/heading.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/accordion/item.blade.php b/src/Core/Front/Components/View/Blade/accordion/item.blade.php new file mode 100644 index 0000000..2890b6b --- /dev/null +++ b/src/Core/Front/Components/View/Blade/accordion/item.blade.php @@ -0,0 +1,7 @@ +@props([ + 'expanded' => false, // Default expanded state +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/autocomplete.blade.php b/src/Core/Front/Components/View/Blade/autocomplete.blade.php new file mode 100644 index 0000000..474a9d8 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/autocomplete.blade.php @@ -0,0 +1,3 @@ +{{-- Core Autocomplete - Flux Pro component. Props: label, description, placeholder, size, disabled, invalid, clearable --}} +@php(\Core\Pro::requireFluxPro('core:autocomplete')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/autocomplete/item.blade.php b/src/Core/Front/Components/View/Blade/autocomplete/item.blade.php new file mode 100644 index 0000000..0509832 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/autocomplete/item.blade.php @@ -0,0 +1,7 @@ +@props([ + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/avatar.blade.php b/src/Core/Front/Components/View/Blade/avatar.blade.php new file mode 100644 index 0000000..8ad5ee7 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/avatar.blade.php @@ -0,0 +1,2 @@ +{{-- Core Avatar - Thin wrapper around flux:avatar. Props: src, initials, alt, size (xs|sm|default|lg|xl) --}} + diff --git a/src/Core/Front/Components/View/Blade/badge.blade.php b/src/Core/Front/Components/View/Blade/badge.blade.php new file mode 100644 index 0000000..272b370 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/badge.blade.php @@ -0,0 +1,18 @@ +@props([ + 'color' => null, // zinc, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose + 'size' => null, // sm, base, lg + 'variant' => null, // solid, outline, pill + 'inset' => null, // top, bottom, left, right + 'icon' => null, // Icon name (Font Awesome) + 'iconTrailing' => null, // Icon name (right side, Font Awesome) +]) + +except(['icon', 'iconTrailing']) }}> + @if($icon) + + @endif + {{ $slot }} + @if($iconTrailing) + + @endif + diff --git a/src/Core/Front/Components/View/Blade/button.blade.php b/src/Core/Front/Components/View/Blade/button.blade.php new file mode 100644 index 0000000..d32655d --- /dev/null +++ b/src/Core/Front/Components/View/Blade/button.blade.php @@ -0,0 +1,34 @@ +{{-- + Core Button - Wrapper around flux:button with Font Awesome icon support + + Props: variant (primary|filled|outline|ghost|danger|subtle), size (xs|sm|base|lg|xl), + icon, iconTrailing, href, type, disabled, loading, square, inset +--}} +@props([ + 'icon' => null, + 'iconTrailing' => null, + 'size' => 'base', + 'square' => null, +]) + +@php + // Determine if this is an icon-only (square) button + $isSquare = $square ?? $slot->isEmpty(); + + // Icon sizes based on button size (matching Flux's sizing) + $iconSize = match($size) { + 'xs' => 'size-3', + 'sm' => $isSquare ? 'size-4' : 'size-3.5', + default => $isSquare ? 'size-5' : 'size-4', + }; +@endphp + +except(['icon', 'iconTrailing', 'icon:trailing'])->merge(['size' => $size, 'square' => $square]) }}> + @if($icon) + + @endif + {{ $slot }} + @if($iconTrailing) + + @endif + diff --git a/src/Core/Front/Components/View/Blade/button/group.blade.php b/src/Core/Front/Components/View/Blade/button/group.blade.php new file mode 100644 index 0000000..e827c4c --- /dev/null +++ b/src/Core/Front/Components/View/Blade/button/group.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/calendar.blade.php b/src/Core/Front/Components/View/Blade/calendar.blade.php new file mode 100644 index 0000000..207a8f1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/calendar.blade.php @@ -0,0 +1,3 @@ +{{-- Core Calendar - Flux Pro component. Props: value, mode, min, max, size, months, navigation, static, multiple, locale --}} +@php(\Core\Pro::requireFluxPro('core:calendar')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/callout.blade.php b/src/Core/Front/Components/View/Blade/callout.blade.php new file mode 100644 index 0000000..f96410e --- /dev/null +++ b/src/Core/Front/Components/View/Blade/callout.blade.php @@ -0,0 +1,2 @@ +{{-- Core Callout - Thin wrapper around flux:callout. Props: variant (default|warning|danger|success), icon, inline --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/callout/heading.blade.php b/src/Core/Front/Components/View/Blade/callout/heading.blade.php new file mode 100644 index 0000000..b93f187 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/callout/heading.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/callout/text.blade.php b/src/Core/Front/Components/View/Blade/callout/text.blade.php new file mode 100644 index 0000000..10d94d1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/callout/text.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/card.blade.php b/src/Core/Front/Components/View/Blade/card.blade.php new file mode 100644 index 0000000..25540ae --- /dev/null +++ b/src/Core/Front/Components/View/Blade/card.blade.php @@ -0,0 +1,7 @@ +@props([ + 'class' => null, // Additional CSS classes +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/chart.blade.php b/src/Core/Front/Components/View/Blade/chart.blade.php new file mode 100644 index 0000000..d30898b --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart.blade.php @@ -0,0 +1,3 @@ +{{-- Core Chart - Flux Pro component. Props: value, curve (smooth|none) --}} +@php(\Core\Pro::requireFluxPro('core:chart')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/chart/area.blade.php b/src/Core/Front/Components/View/Blade/chart/area.blade.php new file mode 100644 index 0000000..a255420 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/area.blade.php @@ -0,0 +1,5 @@ +@props([ + 'field' => null, +]) + + diff --git a/src/Core/Front/Components/View/Blade/chart/axis.blade.php b/src/Core/Front/Components/View/Blade/chart/axis.blade.php new file mode 100644 index 0000000..5f42af9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/axis.blade.php @@ -0,0 +1,7 @@ +@props([ + 'axis' => null, // x, y +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/chart/axis/grid.blade.php b/src/Core/Front/Components/View/Blade/chart/axis/grid.blade.php new file mode 100644 index 0000000..08af16a --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/axis/grid.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/chart/axis/line.blade.php b/src/Core/Front/Components/View/Blade/chart/axis/line.blade.php new file mode 100644 index 0000000..c91eeb2 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/axis/line.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/chart/axis/mark.blade.php b/src/Core/Front/Components/View/Blade/chart/axis/mark.blade.php new file mode 100644 index 0000000..822cd7a --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/axis/mark.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/chart/axis/tick.blade.php b/src/Core/Front/Components/View/Blade/chart/axis/tick.blade.php new file mode 100644 index 0000000..c9c66ca --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/axis/tick.blade.php @@ -0,0 +1,7 @@ +@props([ + 'format' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/chart/cursor.blade.php b/src/Core/Front/Components/View/Blade/chart/cursor.blade.php new file mode 100644 index 0000000..3d26b85 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/cursor.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/chart/legend.blade.php b/src/Core/Front/Components/View/Blade/chart/legend.blade.php new file mode 100644 index 0000000..3e61552 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/legend.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/chart/line.blade.php b/src/Core/Front/Components/View/Blade/chart/line.blade.php new file mode 100644 index 0000000..eef4db5 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/line.blade.php @@ -0,0 +1,5 @@ +@props([ + 'field' => null, +]) + + diff --git a/src/Core/Front/Components/View/Blade/chart/point.blade.php b/src/Core/Front/Components/View/Blade/chart/point.blade.php new file mode 100644 index 0000000..9981dc2 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/point.blade.php @@ -0,0 +1,5 @@ +@props([ + 'field' => null, +]) + + diff --git a/src/Core/Front/Components/View/Blade/chart/summary.blade.php b/src/Core/Front/Components/View/Blade/chart/summary.blade.php new file mode 100644 index 0000000..20842fa --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/summary.blade.php @@ -0,0 +1,7 @@ +@props([ + 'field' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/chart/svg.blade.php b/src/Core/Front/Components/View/Blade/chart/svg.blade.php new file mode 100644 index 0000000..bea4fcd --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/svg.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/chart/tooltip.blade.php b/src/Core/Front/Components/View/Blade/chart/tooltip.blade.php new file mode 100644 index 0000000..08b7504 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/tooltip.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/chart/tooltip/heading.blade.php b/src/Core/Front/Components/View/Blade/chart/tooltip/heading.blade.php new file mode 100644 index 0000000..cfd7c5f --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/tooltip/heading.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/chart/tooltip/value.blade.php b/src/Core/Front/Components/View/Blade/chart/tooltip/value.blade.php new file mode 100644 index 0000000..fbf709a --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/tooltip/value.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/chart/viewport.blade.php b/src/Core/Front/Components/View/Blade/chart/viewport.blade.php new file mode 100644 index 0000000..8857b15 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/chart/viewport.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/checkbox.blade.php b/src/Core/Front/Components/View/Blade/checkbox.blade.php new file mode 100644 index 0000000..c061094 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/checkbox.blade.php @@ -0,0 +1,2 @@ +{{-- Core Checkbox - Thin wrapper around flux:checkbox. Props: label, description, value, disabled, checked, indeterminate --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/checkbox/group.blade.php b/src/Core/Front/Components/View/Blade/checkbox/group.blade.php new file mode 100644 index 0000000..ab2e543 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/checkbox/group.blade.php @@ -0,0 +1,11 @@ +@props([ + 'label' => null, // Group label + 'description' => null, // Help text + 'variant' => null, // cards, segmented + 'wire:model' => null, + 'wire:model.live' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/command.blade.php b/src/Core/Front/Components/View/Blade/command.blade.php new file mode 100644 index 0000000..6d4eabc --- /dev/null +++ b/src/Core/Front/Components/View/Blade/command.blade.php @@ -0,0 +1,3 @@ +{{-- Core Command - Flux Pro component --}} +@php(\Core\Pro::requireFluxPro('core:command')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/command/empty.blade.php b/src/Core/Front/Components/View/Blade/command/empty.blade.php new file mode 100644 index 0000000..16c457e --- /dev/null +++ b/src/Core/Front/Components/View/Blade/command/empty.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/command/input.blade.php b/src/Core/Front/Components/View/Blade/command/input.blade.php new file mode 100644 index 0000000..4102b9b --- /dev/null +++ b/src/Core/Front/Components/View/Blade/command/input.blade.php @@ -0,0 +1,2 @@ +{{-- Core Command Input - Thin wrapper around flux:command.input. Props: clearable, closable, icon, placeholder --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/command/item.blade.php b/src/Core/Front/Components/View/Blade/command/item.blade.php new file mode 100644 index 0000000..63799f8 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/command/item.blade.php @@ -0,0 +1,8 @@ +@props([ + 'icon' => null, + 'kbd' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/command/items.blade.php b/src/Core/Front/Components/View/Blade/command/items.blade.php new file mode 100644 index 0000000..3f8112d --- /dev/null +++ b/src/Core/Front/Components/View/Blade/command/items.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/components/satellite/footer-custom.blade.php b/src/Core/Front/Components/View/Blade/components/satellite/footer-custom.blade.php new file mode 100644 index 0000000..f431c6c --- /dev/null +++ b/src/Core/Front/Components/View/Blade/components/satellite/footer-custom.blade.php @@ -0,0 +1,114 @@ +{{-- + Custom Footer Content Partial + + Variables: + $customContent - Raw HTML content + $customLinks - Array of ['label' => '', 'url' => '', 'icon' => ''] links + $socialLinks - Array of ['platform' => '', 'url' => '', 'icon' => ''] social links + $contactEmail - Email address + $contactPhone - Phone number + $showCopyright - Whether to show copyright (for replace mode) + $copyrightText - Custom copyright text + $workspaceName - Workspace name for default copyright + $appName - App name for default copyright + $appIcon - App icon path +--}} +@php + $showCopyright = $showCopyright ?? false; + $copyrightText = $copyrightText ?? null; + $workspaceName = $workspaceName ?? null; + $appName = $appName ?? config('core.app.name', 'Core PHP'); + $appIcon = $appIcon ?? config('core.app.icon', '/images/icon.svg'); +@endphp + +
+ {{-- Raw HTML custom content --}} + @if(!empty($customContent)) + + @endif + + {{-- Structured content grid --}} + @if(!empty($customLinks) || !empty($socialLinks) || $contactEmail || $contactPhone) +
+ + {{-- Contact information --}} + @if($contactEmail || $contactPhone) +
+ @if($contactEmail) + + + {{ $contactEmail }} + + @endif + @if($contactPhone) + + + {{ $contactPhone }} + + @endif +
+ @endif + + {{-- Custom links --}} + @if(!empty($customLinks)) +
+ @foreach($customLinks as $link) + + @if(!empty($link['icon'])) + + @endif + {{ $link['label'] }} + + @endforeach +
+ @endif + + {{-- Social links --}} + @if(!empty($socialLinks)) +
+ @foreach($socialLinks as $social) + @php + // Get icon from the social link or generate based on platform + $socialIcon = $social['icon'] ?? match(strtolower($social['platform'] ?? '')) { + 'twitter', 'x' => 'fa-brands fa-x-twitter', + 'facebook' => 'fa-brands fa-facebook', + 'instagram' => 'fa-brands fa-instagram', + 'linkedin' => 'fa-brands fa-linkedin', + 'youtube' => 'fa-brands fa-youtube', + 'tiktok' => 'fa-brands fa-tiktok', + 'github' => 'fa-brands fa-github', + 'discord' => 'fa-brands fa-discord', + 'mastodon' => 'fa-brands fa-mastodon', + 'bluesky' => 'fa-brands fa-bluesky', + 'threads' => 'fa-brands fa-threads', + 'pinterest' => 'fa-brands fa-pinterest', + default => 'fa-solid fa-link', + }; + @endphp + + + + @endforeach +
+ @endif +
+ @endif + + {{-- Copyright for replace mode --}} + @if($showCopyright) +
+ {{ $appName }} + + {!! $copyrightText ?? '© '.date('Y').' '.e($workspaceName ?? $appName) !!} + +
+ @endif +
diff --git a/src/Core/Front/Components/View/Blade/components/satellite/layout.blade.php b/src/Core/Front/Components/View/Blade/components/satellite/layout.blade.php new file mode 100644 index 0000000..de39db1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/components/satellite/layout.blade.php @@ -0,0 +1,223 @@ +@php + $appName = config('core.app.name', 'Core PHP'); + $appIcon = config('core.app.icon', '/images/icon.svg'); + $appUrl = config('app.url', 'https://core.test'); + $privacyUrl = config('core.urls.privacy', '/privacy'); + $termsUrl = config('core.urls.terms', '/terms'); + + // Footer settings - can be passed as array or FooterSettings object + $footer = $footer ?? null; + $footerShowDefault = $footer['show_default_links'] ?? $footer?->showDefaultLinks ?? true; + $footerPosition = $footer['position'] ?? $footer?->position ?? 'above_default'; + $footerCustomContent = $footer['custom_content'] ?? $footer?->customContent ?? null; + $footerCustomLinks = $footer['custom_links'] ?? $footer?->customLinks ?? []; + $footerSocialLinks = $footer['social_links'] ?? $footer?->socialLinks ?? []; + $footerCopyright = $footer['copyright_text'] ?? $footer?->copyrightText ?? null; + $footerContactEmail = $footer['contact_email'] ?? $footer?->contactEmail ?? null; + $footerContactPhone = $footer['contact_phone'] ?? $footer?->contactPhone ?? null; + $footerHasCustom = $footerCustomContent || !empty($footerCustomLinks) || !empty($footerSocialLinks) || $footerContactEmail || $footerContactPhone; +@endphp + + + + + + + + {{ $meta['title'] ?? $workspace?->name ?? $appName }} + + + @if(isset($meta['image'])) + + + @endif + + + + + + + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + {{-- Prevent flash of wrong theme --}} + + + + + + + + + + +
+ + +
+
+ + +
+ + + + {{ $slot }} +
+ + +
+ +
+ + {{-- Custom footer content (above default) --}} + @if($footerHasCustom && $footerPosition === 'above_default') + @include('front::components.satellite.footer-custom', [ + 'customContent' => $footerCustomContent, + 'customLinks' => $footerCustomLinks, + 'socialLinks' => $footerSocialLinks, + 'contactEmail' => $footerContactEmail, + 'contactPhone' => $footerContactPhone, + ]) + @endif + + {{-- Custom footer content (replace default) --}} + @if($footerHasCustom && $footerPosition === 'replace_default') + @include('front::components.satellite.footer-custom', [ + 'customContent' => $footerCustomContent, + 'customLinks' => $footerCustomLinks, + 'socialLinks' => $footerSocialLinks, + 'contactEmail' => $footerContactEmail, + 'contactPhone' => $footerContactPhone, + 'showCopyright' => true, + 'copyrightText' => $footerCopyright, + 'workspaceName' => $workspace?->name, + 'appName' => $appName, + 'appIcon' => $appIcon, + ]) + @endif + + {{-- Default footer --}} + @if($footerShowDefault && $footerPosition !== 'replace_default') +
+
+
+ {{ $appName }} + + {!! $footerCopyright ?? '© '.date('Y').' '.e($workspace?->name ?? $appName) !!} + +
+ +
+
+ @endif + + {{-- Custom footer content (below default) --}} + @if($footerHasCustom && $footerPosition === 'below_default') + @include('front::components.satellite.footer-custom', [ + 'customContent' => $footerCustomContent, + 'customLinks' => $footerCustomLinks, + 'socialLinks' => $footerSocialLinks, + 'contactEmail' => $footerContactEmail, + 'contactPhone' => $footerContactPhone, + ]) + @endif +
+ + + diff --git a/src/Core/Front/Components/View/Blade/composer.blade.php b/src/Core/Front/Components/View/Blade/composer.blade.php new file mode 100644 index 0000000..e58444d --- /dev/null +++ b/src/Core/Front/Components/View/Blade/composer.blade.php @@ -0,0 +1,3 @@ +{{-- Core Composer - Flux Pro component. Props: name, placeholder, label, rows, submit, disabled, invalid --}} +@php(\Core\Pro::requireFluxPro('core:composer')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/context.blade.php b/src/Core/Front/Components/View/Blade/context.blade.php new file mode 100644 index 0000000..c98a3e1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/context.blade.php @@ -0,0 +1,3 @@ +{{-- Core Context Menu - Flux Pro component. Props: position, gap, offset, target, disabled --}} +@php(\Core\Pro::requireFluxPro('core:context')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/date-picker.blade.php b/src/Core/Front/Components/View/Blade/date-picker.blade.php new file mode 100644 index 0000000..0030fa2 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/date-picker.blade.php @@ -0,0 +1,3 @@ +{{-- Core Date Picker - Flux Pro component. Props: value, mode, min, max, months, label, placeholder, size, clearable, disabled, locale --}} +@php(\Core\Pro::requireFluxPro('core:date-picker')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/date-picker/button.blade.php b/src/Core/Front/Components/View/Blade/date-picker/button.blade.php new file mode 100644 index 0000000..b628cf1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/date-picker/button.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/date-picker/input.blade.php b/src/Core/Front/Components/View/Blade/date-picker/input.blade.php new file mode 100644 index 0000000..42feb24 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/date-picker/input.blade.php @@ -0,0 +1,7 @@ +@props([ + 'label' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/description.blade.php b/src/Core/Front/Components/View/Blade/description.blade.php new file mode 100644 index 0000000..a03a5d2 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/description.blade.php @@ -0,0 +1,7 @@ +@props([ + 'trailing' => false, // Show below input instead of above +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/dropdown.blade.php b/src/Core/Front/Components/View/Blade/dropdown.blade.php new file mode 100644 index 0000000..e8cc257 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/dropdown.blade.php @@ -0,0 +1,10 @@ +@props([ + 'position' => null, // top, right, bottom, left + 'align' => null, // start, center, end + 'offset' => null, // pixels + 'gap' => null, // pixels +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/editor.blade.php b/src/Core/Front/Components/View/Blade/editor.blade.php new file mode 100644 index 0000000..310f778 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/editor.blade.php @@ -0,0 +1,3 @@ +{{-- Core Editor - Flux Pro component. Props: value, label, description, placeholder, toolbar, disabled, invalid --}} +@php(\Core\Pro::requireFluxPro('core:editor')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/editor/button.blade.php b/src/Core/Front/Components/View/Blade/editor/button.blade.php new file mode 100644 index 0000000..19362c1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/editor/button.blade.php @@ -0,0 +1,10 @@ +@props([ + 'icon' => null, + 'iconVariant' => null, // icon-variant + 'tooltip' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/editor/content.blade.php b/src/Core/Front/Components/View/Blade/editor/content.blade.php new file mode 100644 index 0000000..9cefcb5 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/editor/content.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/editor/toolbar.blade.php b/src/Core/Front/Components/View/Blade/editor/toolbar.blade.php new file mode 100644 index 0000000..72863ba --- /dev/null +++ b/src/Core/Front/Components/View/Blade/editor/toolbar.blade.php @@ -0,0 +1,7 @@ +@props([ + 'items' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/error.blade.php b/src/Core/Front/Components/View/Blade/error.blade.php new file mode 100644 index 0000000..a6414a5 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/error.blade.php @@ -0,0 +1,7 @@ +@props([ + 'name' => null, // Form field name for validation error +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/errors/404.blade.php b/src/Core/Front/Components/View/Blade/errors/404.blade.php new file mode 100644 index 0000000..2899fda --- /dev/null +++ b/src/Core/Front/Components/View/Blade/errors/404.blade.php @@ -0,0 +1,99 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appIcon = config('core.app.icon', '/images/icon.svg'); +@endphp + + + + + + {{ __('core::core.errors.404.title') }} - {{ $appName }} + + + + +
+ 404 illustration +
404
+

{{ __('core::core.errors.404.heading') }}

+

{{ __('core::core.errors.404.message') }}

+ +
+ + diff --git a/src/Core/Front/Components/View/Blade/errors/500.blade.php b/src/Core/Front/Components/View/Blade/errors/500.blade.php new file mode 100644 index 0000000..9f234e9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/errors/500.blade.php @@ -0,0 +1,101 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appIcon = config('core.app.icon', '/images/icon.svg'); +@endphp + + + + + + {{ __('core::core.errors.500.title') }} - {{ $appName }} + + + + +
+ 500 illustration +
500
+

{{ __('core::core.errors.500.heading') }}

+

{{ __('core::core.errors.500.message') }}

+
+ + {{ __('core::core.errors.500.back_home') }} +
+
+ + diff --git a/src/Core/Front/Components/View/Blade/errors/503.blade.php b/src/Core/Front/Components/View/Blade/errors/503.blade.php new file mode 100644 index 0000000..0223bf0 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/errors/503.blade.php @@ -0,0 +1,101 @@ +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appIcon = config('core.app.icon', '/images/icon.svg'); + $statusUrl = config('core.urls.status'); +@endphp + + + + + + {{ __('core::core.errors.503.title') }} - {{ $appName }} + + + + +
+ Maintenance illustration +
503
+

{{ __('core::core.errors.503.heading') }}

+

{{ __('core::core.errors.503.message') }}

+ @if($statusUrl) + + + + + + {{ __('core::core.errors.503.check_status') }} + + @endif +

{{ __('core::core.errors.503.auto_refresh') }}

+
+ + + diff --git a/src/Core/Front/Components/View/Blade/examples/blog-post.blade.php b/src/Core/Front/Components/View/Blade/examples/blog-post.blade.php new file mode 100644 index 0000000..86a8044 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/examples/blog-post.blade.php @@ -0,0 +1,92 @@ +{{-- +Example: Blog Post using Content Layout +Route: /examples/blog-post +--}} + + + +

+ Building an audience takes time, but with the right tools and strategies, you can accelerate your growth + and create a community that genuinely engages with your content. +

+ +

Start with your niche

+ +

+ The most successful creators focus on a specific niche before expanding. This doesn't mean you're limited + forever, but starting focused helps you build authority and attract a dedicated audience who knows exactly + what to expect from you. +

+ +

+ Consider what makes you unique. Your perspective, experience, or approach to topics can differentiate you + from others in your space. +

+ +
+

"The riches are in the niches. Find your specific audience first, then expand from a position of strength."

+
+ +

Consistency beats perfection

+ +

+ One of the biggest mistakes new creators make is waiting for perfect conditions. The algorithm rewards + consistency, and your audience builds habits around your posting schedule. +

+ +
    +
  • Post at regular intervals your audience can rely on
  • +
  • Batch create content to maintain consistency during busy periods
  • +
  • Use scheduling tools like Host Social to automate posting
  • +
  • Track what works and iterate on successful formats
  • +
+ +

Engage authentically

+ +

+ Social media is a two-way conversation. The creators who build the strongest communities are those who + genuinely engage with their audience, respond to comments, and create content based on feedback. +

+ +

Respond to comments quickly

+ +

+ The first hour after posting is crucial. Being present to respond to early comments signals to the algorithm + that your content is generating engagement, which can boost its reach. +

+ +

Ask questions

+ +

+ End your posts with questions that invite discussion. This transforms passive viewers into active participants + and helps you understand what your audience wants. +

+ +
+ +

Tools that help

+ +

+ Host Social makes managing multiple platforms simple. Schedule your content once and let it publish across + all your channels automatically. This frees up time for what matters most: creating great content and + engaging with your community. +

+ + +
+

Ready to grow your audience?

+

Start scheduling your content with Host Social today.

+ + Get Started Free + +
+
+ +
diff --git a/src/Core/Front/Components/View/Blade/examples/checkout.blade.php b/src/Core/Front/Components/View/Blade/examples/checkout.blade.php new file mode 100644 index 0000000..530a147 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/examples/checkout.blade.php @@ -0,0 +1,115 @@ +{{-- +Example: Checkout Form using Focused Layout +Route: /examples/checkout +--}} + + + +
+ @csrf + + +
+
+
Creator Pro
+
Monthly billing
+
+
+
£29
+
/month
+
+
+ + +
+

Billing details

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+

Payment method

+ +
+ +
+ +
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+ + + + + +
+ + Secured by Stripe. Your payment info is encrypted. +
+
+ + +

Have a promo code? Apply it here

+
+ +
diff --git a/src/Core/Front/Components/View/Blade/examples/guide.blade.php b/src/Core/Front/Components/View/Blade/examples/guide.blade.php new file mode 100644 index 0000000..1e2f518 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/examples/guide.blade.php @@ -0,0 +1,159 @@ +{{-- +Example: Guide with TOC using Sidebar Right Layout +Route: /examples/guide +--}} + + + + + Introduction + Connecting accounts + Creating posts + Scheduling + Analytics + Best practices + Troubleshooting + + +

Introduction

+ +

+ Host Social is your all-in-one social media management platform. Schedule posts, track performance, + and manage all your social accounts from a single dashboard. +

+ +

+ This guide will walk you through everything you need to know to get started and make the most of + Host Social's features. +

+ +

Connecting your social accounts

+ +

+ Before you can start scheduling posts, you'll need to connect your social media accounts. + Host Social supports the following platforms: +

+ +
    +
  • Instagram - Business and Creator accounts
  • +
  • Twitter/X - Personal and Business accounts
  • +
  • Facebook - Pages and Groups
  • +
  • LinkedIn - Personal and Company pages
  • +
  • TikTok - Creator and Business accounts
  • +
  • YouTube - Channels with upload permission
  • +
+ +

To connect an account:

+ +
    +
  1. Navigate to Settings → Connected Accounts
  2. +
  3. Click the platform you want to connect
  4. +
  5. Authorise Host Social to access your account
  6. +
  7. Select which profile or page to use
  8. +
+ +
+

Tip: Connect all your accounts at once to enable cross-posting from day one.

+
+ +

Creating posts

+ +

+ Host Social makes it easy to create content that works across multiple platforms. The composer + automatically adapts your content to each platform's requirements. +

+ +

Using the post composer

+ +

+ Click Create Post to open the composer. You can write your content once and + customise it for each platform if needed. +

+ +
Example post structure:
+- Hook (first line that grabs attention)
+- Value (the main content)
+- Call to action (what you want readers to do)
+ +

Adding media

+ +

+ Drag and drop images or videos into the composer, or click the media button to upload. + Host Social will automatically resize and optimise your media for each platform. +

+ +

Scheduling your posts

+ +

+ Once you've created your post, you can either publish immediately or schedule it for later. + Use the calendar view to plan your content strategy across the week or month. +

+ +

Best times to post

+ +

+ Host Social analyses your audience engagement and suggests optimal posting times. + Look for the green indicators on the calendar for + high-engagement windows. +

+ +

Understanding your analytics

+ +

+ Track your performance across all platforms from a single dashboard. Key metrics include: +

+ +
    +
  • Reach - How many people saw your content
  • +
  • Engagement - Likes, comments, shares, and saves
  • +
  • Click-through rate - For posts with links
  • +
  • Follower growth - Net new followers over time
  • +
+ +

Best practices

+ +

+ To get the most out of Host Social, follow these recommendations: +

+ +
    +
  1. Batch your content - Create a week's worth of posts in one session
  2. +
  3. Use the preview - Always check how your post looks on each platform
  4. +
  5. Mix content types - Alternate between images, videos, and text posts
  6. +
  7. Engage after posting - Set reminders to respond to comments
  8. +
  9. Review analytics weekly - Adjust your strategy based on performance
  10. +
+ +

Troubleshooting

+ +

Post failed to publish

+ +

+ If a scheduled post fails, check the following: +

+ +
    +
  • Your account connection is still active (re-authorise if needed)
  • +
  • The content meets platform guidelines
  • +
  • Media files are under the size limit
  • +
+ +

Account disconnected

+ +

+ Social platforms occasionally require re-authorisation. If your account shows as disconnected, + simply click Reconnect and authorise again. +

+ +
+ +

+ Need more help? Visit the Help Centre or + contact support. +

+ +
diff --git a/src/Core/Front/Components/View/Blade/examples/help-centre.blade.php b/src/Core/Front/Components/View/Blade/examples/help-centre.blade.php new file mode 100644 index 0000000..09f6ae7 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/examples/help-centre.blade.php @@ -0,0 +1,117 @@ +{{-- +Example: Help Centre using Sidebar Left Layout +Route: /examples/help-centre +--}} + + + + + + General + + + Billing + + + Account + + + Host Social + + + Host Link + + + API + + + + +
+
+

Frequently Asked Questions

+ +
+ +
+ +
+

Getting started is simple. Create an account, verify your email, and you'll have immediate access to your Host Hub dashboard. From there, you can set up your bio pages, connect your social accounts, and start scheduling content.

+
+
+ + +
+ +
+

Host UK accepts all major credit and debit cards (Visa, Mastercard, American Express) as well as PayPal. All payments are processed securely through Stripe.

+
+
+ + +
+ +
+

Yes, you can cancel your subscription at any time from your account settings. Your access will continue until the end of your current billing period, and you won't be charged again.

+
+
+ + +
+ +
+

Host UK offers a 14-day money-back guarantee for new subscriptions. If you're not satisfied within the first 14 days, contact the support team for a full refund.

+
+
+ + +
+ +
+

Navigate to Host Social in your dashboard and click "Connect Account". You'll be guided through the OAuth process for each platform. Host Social supports Instagram, Twitter/X, Facebook, LinkedIn, TikTok, and YouTube.

+
+
+
+
+ + +
+
+ +
+

Still need help?

+

The support team is available Monday to Friday, 9am to 6pm GMT.

+ + Contact Support + +
+
+ +
diff --git a/src/Core/Front/Components/View/Blade/examples/hlcrf-test.blade.php b/src/Core/Front/Components/View/Blade/examples/hlcrf-test.blade.php new file mode 100644 index 0000000..0bca6ee --- /dev/null +++ b/src/Core/Front/Components/View/Blade/examples/hlcrf-test.blade.php @@ -0,0 +1,143 @@ +{{-- HLCRF Layout Test Page --}} +{{-- Tests: HCF, HLCF, HLCRF, LCR, CF variants --}} + +
+ + {{-- Test 0: Two layouts side by side - slot isolation test --}} +
+

Test 0: Slot Isolation (Two Layouts Side by Side)

+
+ + +
Layout A Header
+
+
Layout A Content
+ +
Layout A Footer
+
+
+ + + +
Layout B Header
+
+
Layout B Content
+ +
Layout B Footer
+
+
+
+
+ + {{-- Test 1: HCF (Header, Content, Footer) - most common --}} +
+

Test 1: HCF (Default)

+ + +
Header
+
+ +
Content (default slot)
+ + +
Footer
+
+
+
+ + {{-- Test 2: HLCF (Header, Left, Content, Footer) - admin dashboards --}} +
+

Test 2: HLCF (Admin Dashboard)

+ + +
Header
+
+ + +
Left Sidebar
+
+ +
Main Content
+ + +
Footer
+
+
+
+ + {{-- Test 3: HLCRF (Full layout) --}} +
+

Test 3: HLCRF (Full)

+ + +
Header
+
+ + +
Left
+
+ +
Content
+ + +
Right
+
+ + +
Footer
+
+
+
+ + {{-- Test 4: LCR (No header/footer - app body) --}} +
+

Test 4: LCR (App Body)

+ + +
Nav
+
+ +
Content
+ + +
Aside
+
+
+
+ + {{-- Test 5: C (Content only - minimal) --}} +
+

Test 5: C (Content Only)

+ +
Just content, nothing else
+
+
+ + {{-- Test 6: Nested HLCRF --}} +
+

Test 6: Nested (LCR inside HCF)

+ + +
App Header
+
+ + {{-- Nested layout in content --}} + + +
Sidebar
+
+ +
Nested Content
+ + +
Panel
+
+
+ + +
App Footer
+
+
+
+ +
diff --git a/src/Core/Front/Components/View/Blade/field.blade.php b/src/Core/Front/Components/View/Blade/field.blade.php new file mode 100644 index 0000000..b48c606 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/field.blade.php @@ -0,0 +1,7 @@ +@props([ + 'variant' => null, // inline, stacked +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/file-item.blade.php b/src/Core/Front/Components/View/Blade/file-item.blade.php new file mode 100644 index 0000000..b69b531 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/file-item.blade.php @@ -0,0 +1,12 @@ +@props([ + 'heading' => null, + 'text' => null, + 'image' => null, + 'size' => null, + 'icon' => null, + 'invalid' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/file-item/remove.blade.php b/src/Core/Front/Components/View/Blade/file-item/remove.blade.php new file mode 100644 index 0000000..cc53cd6 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/file-item/remove.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/file-upload.blade.php b/src/Core/Front/Components/View/Blade/file-upload.blade.php new file mode 100644 index 0000000..f94225d --- /dev/null +++ b/src/Core/Front/Components/View/Blade/file-upload.blade.php @@ -0,0 +1,3 @@ +{{-- Core File Upload - Flux Pro component. Props: name, multiple, label, description, error, disabled --}} +@php(\Core\Pro::requireFluxPro('core:file-upload')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/file-upload/dropzone.blade.php b/src/Core/Front/Components/View/Blade/file-upload/dropzone.blade.php new file mode 100644 index 0000000..c83fdf0 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/file-upload/dropzone.blade.php @@ -0,0 +1,11 @@ +@props([ + 'heading' => null, + 'text' => null, + 'icon' => null, + 'inline' => false, + 'withProgress' => false, // with-progress +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/forms/button.blade.php b/src/Core/Front/Components/View/Blade/forms/button.blade.php new file mode 100644 index 0000000..27de20d --- /dev/null +++ b/src/Core/Front/Components/View/Blade/forms/button.blade.php @@ -0,0 +1,34 @@ +{{-- +Authorization-aware button component. + +Wraps flux:button with built-in authorization checking. + +Usage: +Save +Save +Save +--}} + +@props([ + 'canGate' => null, + 'canResource' => null, + 'variant' => 'primary', + 'type' => 'submit', +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } +@endphp + +except(['disabled', 'variant', 'type'])->merge([ + 'type' => $type, + 'variant' => $variant, + 'disabled' => $disabled, + ]) }} +> + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/forms/checkbox.blade.php b/src/Core/Front/Components/View/Blade/forms/checkbox.blade.php new file mode 100644 index 0000000..a441fe3 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/forms/checkbox.blade.php @@ -0,0 +1,54 @@ +{{-- +Authorization-aware checkbox component. + +Wraps flux:checkbox with built-in authorization checking. + +Usage: + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + except(['disabled', 'wire:model', 'wire:model.live'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + > + @if($label) + {{ $label }} + @endif + + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/src/Core/Front/Components/View/Blade/forms/input.blade.php b/src/Core/Front/Components/View/Blade/forms/input.blade.php new file mode 100644 index 0000000..bcf15dc --- /dev/null +++ b/src/Core/Front/Components/View/Blade/forms/input.blade.php @@ -0,0 +1,54 @@ +{{-- +Authorization-aware input component. + +Wraps flux:input with built-in authorization checking. + +Usage: + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live.debounce.500ms="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + /> + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/src/Core/Front/Components/View/Blade/forms/select.blade.php b/src/Core/Front/Components/View/Blade/forms/select.blade.php new file mode 100644 index 0000000..bbba146 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/forms/select.blade.php @@ -0,0 +1,65 @@ +{{-- +Authorization-aware select component. + +Wraps flux:select with built-in authorization checking. + +Usage: + + + + + + + Light + Dark + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, + 'placeholder' => null, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live', 'placeholder'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + 'placeholder' => $placeholder, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + > + {{ $slot }} + + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/src/Core/Front/Components/View/Blade/forms/textarea.blade.php b/src/Core/Front/Components/View/Blade/forms/textarea.blade.php new file mode 100644 index 0000000..540aa82 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/forms/textarea.blade.php @@ -0,0 +1,56 @@ +{{-- +Authorization-aware textarea component. + +Wraps flux:textarea with built-in authorization checking. + +Usage: + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, + 'rows' => 3, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + + @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live', 'rows'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + 'rows' => $rows, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live.debounce.500ms="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + /> + + @if($helper) + {{ $helper }} + @endif + + + diff --git a/src/Core/Front/Components/View/Blade/forms/toggle.blade.php b/src/Core/Front/Components/View/Blade/forms/toggle.blade.php new file mode 100644 index 0000000..689fae7 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/forms/toggle.blade.php @@ -0,0 +1,57 @@ +{{-- +Authorization-aware toggle/switch component. + +Wraps flux:switch with built-in authorization checking. + +Usage: + + + +--}} + +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'canGate' => null, + 'canResource' => null, + 'instantSave' => false, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } + + $wireModel = $attributes->wire('model')->value(); +@endphp + + +
+ @if($label) + {{ $label }} + @endif + + except(['disabled', 'wire:model', 'wire:model.live'])->merge([ + 'id' => $id, + 'name' => $id, + 'disabled' => $disabled, + ]) }} + @if($wireModel) + @if($instantSave) + wire:model.live="{{ $wireModel }}" + @else + wire:model="{{ $wireModel }}" + @endif + @endif + /> +
+ + @if($helper) + {{ $helper }} + @endif + + +
diff --git a/src/Core/Front/Components/View/Blade/heading.blade.php b/src/Core/Front/Components/View/Blade/heading.blade.php new file mode 100644 index 0000000..edd87fb --- /dev/null +++ b/src/Core/Front/Components/View/Blade/heading.blade.php @@ -0,0 +1,2 @@ +{{-- Core Heading - Thin wrapper around flux:heading. Props: level (1-6), size (xs|sm|base|lg|xl|2xl) --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/icon.blade.php b/src/Core/Front/Components/View/Blade/icon.blade.php new file mode 100644 index 0000000..97ac773 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon.blade.php @@ -0,0 +1,154 @@ +{{-- + Core Icon - FontAwesome Implementation with Pro/Free Detection + + Uses FontAwesome with automatic brand/jelly detection: + - Brand icons (github, twitter, etc.) → fa-brands + - Jelly icons (globe, clock, etc.) → fa-jelly (Pro) or fa-solid (Free fallback) + - All others → fa-solid (or explicit style override) + + Pro styles: solid, regular, light, thin, duotone, brands, sharp, jelly + Free styles: solid, regular, brands (others fall back automatically) + + Props: name, style (solid|regular|light|thin|duotone|brands|jelly), + size (xs|sm|lg|xl|2xl), spin, pulse, flip, rotate, fw +--}} +@props([ + 'name', + 'style' => null, // Override: solid, regular, light, thin, duotone, brands, jelly + 'size' => null, // Size class: xs, sm, lg, xl, 2xl, etc. + 'spin' => false, // Animate spinning + 'pulse' => false, // Animate pulsing + 'flip' => null, // horizontal, vertical, both + 'rotate' => null, // 90, 180, 270 + 'fw' => false, // Fixed width +]) + +@php + use Core\Pro; + + // Brand icons - always use fa-brands (available in Free) + $brandIcons = [ + // Social + 'facebook', 'facebook-f', 'facebook-messenger', 'instagram', 'twitter', 'x-twitter', + 'tiktok', 'youtube', 'linkedin', 'linkedin-in', 'pinterest', 'pinterest-p', + 'snapchat', 'snapchat-ghost', 'whatsapp', 'telegram', 'telegram-plane', + 'discord', 'twitch', 'reddit', 'reddit-alien', 'threads', 'mastodon', 'bluesky', + // Media + 'spotify', 'soundcloud', 'apple', 'itunes', 'itunes-note', 'bandcamp', + 'deezer', 'napster', 'audible', 'vimeo', 'vimeo-v', 'dailymotion', + // Dev/Tech + 'github', 'github-alt', 'gitlab', 'bitbucket', 'dribbble', 'behance', + 'figma', 'sketch', 'codepen', 'jsfiddle', 'stack-overflow', + 'npm', 'node', 'node-js', 'js', 'php', 'python', 'java', 'rust', + 'react', 'vuejs', 'angular', 'laravel', 'symfony', 'docker', + 'aws', 'google', 'microsoft', + // Commerce + 'shopify', 'etsy', 'amazon', 'ebay', 'paypal', 'stripe', 'cc-stripe', + 'cc-visa', 'cc-mastercard', 'cc-amex', 'cc-paypal', 'cc-apple-pay', + 'bitcoin', 'btc', 'ethereum', 'monero', + // Communication + 'slack', 'slack-hash', 'skype', 'viber', 'line', 'wechat', 'qq', + // Other + 'wordpress', 'wordpress-simple', 'medium', 'blogger', 'tumblr', + 'patreon', 'kickstarter', 'product-hunt', 'airbnb', 'uber', 'lyft', + 'yelp', 'tripadvisor', + ]; + + // Jelly style icons - full list from FA Pro+ metadata + // Generated from ~/Code/lib/fontawesome/metadata/icon-families.json + $jellyIcons = [ + 'address-card', 'alarm-clock', 'anchor', 'angle-down', 'angle-left', + 'angle-right', 'angle-up', 'arrow-down', 'arrow-down-to-line', + 'arrow-down-wide-short', 'arrow-left', 'arrow-right', + 'arrow-right-arrow-left', 'arrow-right-from-bracket', + 'arrow-right-to-bracket', 'arrow-rotate-left', 'arrow-rotate-right', + 'arrow-up', 'arrow-up-from-bracket', 'arrow-up-from-line', + 'arrow-up-right-from-square', 'arrow-up-wide-short', 'arrows-rotate', + 'at', 'backward', 'backward-step', 'bag-shopping', 'bars', + 'battery-bolt', 'battery-empty', 'battery-half', 'battery-low', + 'battery-three-quarters', 'bed', 'bell', 'block-quote', 'bold', 'bolt', + 'bomb', 'book', 'book-open', 'bookmark', 'box', 'box-archive', 'bug', + 'building', 'bus', 'cake-candles', 'calendar', 'camera', 'camera-slash', + 'car', 'cart-shopping', 'chart-bar', 'chart-pie', 'check', 'circle', + 'circle-check', 'circle-half-stroke', 'circle-info', 'circle-plus', + 'circle-question', 'circle-user', 'circle-xmark', 'city', 'clipboard', + 'clock', 'clone', 'cloud', 'code', 'command', 'comment', 'comment-dots', + 'comments', 'compact-disc', 'compass', 'compress', 'credit-card', + 'crown', 'database', 'desktop', 'door-closed', 'droplet', 'ellipsis', + 'envelope', 'equals', 'expand', 'eye', 'eye-slash', 'face-frown', + 'face-grin', 'face-meh', 'face-smile', 'file', 'files', 'film', + 'filter', 'fire', 'fish', 'flag', 'flower', 'folder', 'folders', 'font', + 'font-awesome', 'font-case', 'forward', 'forward-step', 'gamepad', + 'gauge', 'gear', 'gift', 'globe', 'grid', 'hand', 'headphones', 'heart', + 'heart-half', 'hourglass', 'house', 'image', 'images', 'inbox', + 'italic', 'key', 'landmark', 'language', 'laptop', 'layer-group', + 'leaf', 'life-ring', 'lightbulb', 'link', 'list', 'list-ol', + 'location-arrow', 'location-dot', 'lock', 'lock-open', + 'magnifying-glass', 'magnifying-glass-minus', 'magnifying-glass-plus', + 'map', 'martini-glass', 'microphone', 'microphone-slash', 'minus', + 'mobile', 'money-bill', 'moon', 'mug-hot', 'music', 'newspaper', + 'notdef', 'palette', 'paper-plane', 'paperclip', 'pause', 'paw', + 'pencil', 'percent', 'person-biking', 'phone', 'phone-slash', 'plane', + 'play', 'play-pause', 'plus', 'print', 'question', 'quote-left', + 'rectangle', 'rectangle-tall', 'rectangle-vertical', 'rectangle-wide', + 'scissors', 'share-nodes', 'shield', 'shield-halved', 'ship', 'shirt', + 'shop', 'sidebar', 'sidebar-flip', 'signal-bars', 'signal-bars-fair', + 'signal-bars-good', 'signal-bars-slash', 'signal-bars-weak', 'skull', + 'sliders', 'snowflake', 'sort', 'sparkles', 'square', 'square-code', + 'star', 'star-half', 'stop', 'stopwatch', 'strikethrough', 'suitcase', + 'sun', 'tag', 'terminal', 'thumbs-down', 'thumbs-up', 'thumbtack', + 'ticket', 'train', 'trash', 'tree', 'triangle', 'triangle-exclamation', + 'trophy', 'truck', 'tv-retro', 'umbrella', 'universal-access', 'user', + 'users', 'utensils', 'video', 'video-slash', 'volume', 'volume-low', + 'volume-off', 'volume-slash', 'volume-xmark', 'wand-magic-sparkles', + 'wheelchair-move', 'wifi', 'wifi-fair', 'wifi-slash', 'wifi-weak', + 'wrench', 'xmark', + ]; + + // Pro-only style fallbacks (when FA Pro not available) + $proStyleFallbacks = [ + 'light' => 'regular', + 'thin' => 'regular', + 'duotone' => 'solid', + 'sharp' => 'solid', + 'jelly' => 'solid', + ]; + + $hasFaPro = Pro::hasFontAwesomePro(); + + // Determine raw style + if ($style) { + $rawStyle = match($style) { + 'brands', 'brand' => 'brands', + default => $style, + }; + } elseif (in_array($name, $brandIcons)) { + $rawStyle = 'brands'; + } elseif (in_array($name, $jellyIcons)) { + $rawStyle = 'jelly'; + } else { + $rawStyle = 'solid'; + } + + // Apply fallback if Pro not available + $finalStyle = $rawStyle; + if (!$hasFaPro && isset($proStyleFallbacks[$rawStyle])) { + $finalStyle = $proStyleFallbacks[$rawStyle]; + } + + $iconStyle = "fa-{$finalStyle}"; + + // Build classes + $classes = collect([ + $iconStyle, + "fa-{$name}", + $size ? "fa-{$size}" : null, + $spin ? 'fa-spin' : null, + $pulse ? 'fa-pulse' : null, + $flip ? "fa-flip-{$flip}" : null, + $rotate ? "fa-rotate-{$rotate}" : null, + $fw ? 'fa-fw' : null, + ])->filter()->implode(' '); +@endphp + +class($classes) }} aria-hidden="true"> diff --git a/src/Core/Front/Components/View/Blade/icon/check-circle.blade.php b/src/Core/Front/Components/View/Blade/icon/check-circle.blade.php new file mode 100644 index 0000000..62ebec9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/check-circle.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/check.blade.php b/src/Core/Front/Components/View/Blade/icon/check.blade.php new file mode 100644 index 0000000..c4c8b69 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/check.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/clipboard.blade.php b/src/Core/Front/Components/View/Blade/icon/clipboard.blade.php new file mode 100644 index 0000000..b6d9ebe --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/clipboard.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/clock.blade.php b/src/Core/Front/Components/View/Blade/icon/clock.blade.php new file mode 100644 index 0000000..21d1ed5 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/clock.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/code-bracket-square.blade.php b/src/Core/Front/Components/View/Blade/icon/code-bracket-square.blade.php new file mode 100644 index 0000000..149c60e --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/code-bracket-square.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/code-bracket.blade.php b/src/Core/Front/Components/View/Blade/icon/code-bracket.blade.php new file mode 100644 index 0000000..5aa3917 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/code-bracket.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/document-text.blade.php b/src/Core/Front/Components/View/Blade/icon/document-text.blade.php new file mode 100644 index 0000000..e60f75c --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/document-text.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/key.blade.php b/src/Core/Front/Components/View/Blade/icon/key.blade.php new file mode 100644 index 0000000..91b0e73 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/key.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/lock-closed.blade.php b/src/Core/Front/Components/View/Blade/icon/lock-closed.blade.php new file mode 100644 index 0000000..7172fa5 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/lock-closed.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/x-circle.blade.php b/src/Core/Front/Components/View/Blade/icon/x-circle.blade.php new file mode 100644 index 0000000..431143a --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/x-circle.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/icon/x-mark.blade.php b/src/Core/Front/Components/View/Blade/icon/x-mark.blade.php new file mode 100644 index 0000000..b2d2a22 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/icon/x-mark.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/input.blade.php b/src/Core/Front/Components/View/Blade/input.blade.php new file mode 100644 index 0000000..131187e --- /dev/null +++ b/src/Core/Front/Components/View/Blade/input.blade.php @@ -0,0 +1,2 @@ +{{-- Core Input - Thin wrapper around flux:input. Props: type, name, placeholder, value, label, description, icon, iconTrailing, badge, size, variant, disabled, readonly, required, autocomplete, clearable, viewable, copyable --}} + diff --git a/src/Core/Front/Components/View/Blade/input/group.blade.php b/src/Core/Front/Components/View/Blade/input/group.blade.php new file mode 100644 index 0000000..7841558 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/input/group.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/input/group/prefix.blade.php b/src/Core/Front/Components/View/Blade/input/group/prefix.blade.php new file mode 100644 index 0000000..fb59e9c --- /dev/null +++ b/src/Core/Front/Components/View/Blade/input/group/prefix.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/kanban.blade.php b/src/Core/Front/Components/View/Blade/kanban.blade.php new file mode 100644 index 0000000..c4370ab --- /dev/null +++ b/src/Core/Front/Components/View/Blade/kanban.blade.php @@ -0,0 +1,3 @@ +{{-- Core Kanban - Flux Pro component --}} +@php(\Core\Pro::requireFluxPro('core:kanban')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/kanban/card.blade.php b/src/Core/Front/Components/View/Blade/kanban/card.blade.php new file mode 100644 index 0000000..810203a --- /dev/null +++ b/src/Core/Front/Components/View/Blade/kanban/card.blade.php @@ -0,0 +1,8 @@ +@props([ + 'heading' => null, + 'as' => null, // button, div +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/kanban/column.blade.php b/src/Core/Front/Components/View/Blade/kanban/column.blade.php new file mode 100644 index 0000000..7560a61 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/kanban/column.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/kanban/column/cards.blade.php b/src/Core/Front/Components/View/Blade/kanban/column/cards.blade.php new file mode 100644 index 0000000..11ceb35 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/kanban/column/cards.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/kanban/column/footer.blade.php b/src/Core/Front/Components/View/Blade/kanban/column/footer.blade.php new file mode 100644 index 0000000..e5a673e --- /dev/null +++ b/src/Core/Front/Components/View/Blade/kanban/column/footer.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/kanban/column/header.blade.php b/src/Core/Front/Components/View/Blade/kanban/column/header.blade.php new file mode 100644 index 0000000..73abe5b --- /dev/null +++ b/src/Core/Front/Components/View/Blade/kanban/column/header.blade.php @@ -0,0 +1,10 @@ +@props([ + 'heading' => null, + 'subheading' => null, + 'count' => null, + 'badge' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/label.blade.php b/src/Core/Front/Components/View/Blade/label.blade.php new file mode 100644 index 0000000..6bbef16 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/label.blade.php @@ -0,0 +1,7 @@ +@props([ + 'for' => null, // Associated input ID +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/layout.blade.php b/src/Core/Front/Components/View/Blade/layout.blade.php new file mode 100644 index 0000000..0317537 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layout.blade.php @@ -0,0 +1,41 @@ +@props(['variant' => 'HCF']) + +@php + $has = fn($slot) => str_contains($variant, $slot); +@endphp + +
merge(['class' => 'hlcrf-layout']) }}> + @if($has('H') && isset($header)) +
+ {{ $header }} +
+ @endif + + @if($has('L') || $has('C') || $has('R')) +
+ @if($has('L') && isset($left)) + + @endif + + @if($has('C')) +
+ {{ $slot }} +
+ @endif + + @if($has('R') && isset($right)) + + @endif +
+ @endif + + @if($has('F') && isset($footer)) +
+ {{ $footer }} +
+ @endif +
diff --git a/src/Core/Front/Components/View/Blade/layout/content.blade.php b/src/Core/Front/Components/View/Blade/layout/content.blade.php new file mode 100644 index 0000000..dadb254 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layout/content.blade.php @@ -0,0 +1,3 @@ +
merge(['class' => 'hlcrf-content']) }}> + {{ $slot }} +
diff --git a/src/Core/Front/Components/View/Blade/layout/footer.blade.php b/src/Core/Front/Components/View/Blade/layout/footer.blade.php new file mode 100644 index 0000000..fa4a394 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layout/footer.blade.php @@ -0,0 +1,3 @@ +
merge(['class' => 'hlcrf-footer']) }}> + {{ $slot }} +
diff --git a/src/Core/Front/Components/View/Blade/layout/header.blade.php b/src/Core/Front/Components/View/Blade/layout/header.blade.php new file mode 100644 index 0000000..9651997 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layout/header.blade.php @@ -0,0 +1,3 @@ +
merge(['class' => 'hlcrf-header']) }}> + {{ $slot }} +
diff --git a/src/Core/Front/Components/View/Blade/layout/left.blade.php b/src/Core/Front/Components/View/Blade/layout/left.blade.php new file mode 100644 index 0000000..33ee0b9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layout/left.blade.php @@ -0,0 +1,3 @@ + diff --git a/src/Core/Front/Components/View/Blade/layout/right.blade.php b/src/Core/Front/Components/View/Blade/layout/right.blade.php new file mode 100644 index 0000000..34610f9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layout/right.blade.php @@ -0,0 +1,3 @@ + diff --git a/src/Core/Front/Components/View/Blade/layouts/app.blade.php b/src/Core/Front/Components/View/Blade/layouts/app.blade.php new file mode 100644 index 0000000..d4196ba --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/app.blade.php @@ -0,0 +1,32 @@ +@props([ + 'title' => null, +]) + +{{-- +Marketing/Sales Layout (app.blade.php) +Use for: Landing pages, pricing, about, services - anything with particle animation and sales focus + +Other layouts available: +- content.blade.php → Blog posts, guides, legal pages (centred prose) +- sidebar-left.blade.php → Help centre, FAQ, documentation (left nav + content) +- sidebar-right.blade.php → Long guides with TOC (content + right sidebar) +- focused.blade.php → Checkout, forms, onboarding (minimal, focused) +--}} + + + +
+ + + + +
+
+ {{ $slot }} +
+
+ + + +
+
diff --git a/src/Core/Front/Components/View/Blade/layouts/content.blade.php b/src/Core/Front/Components/View/Blade/layouts/content.blade.php new file mode 100644 index 0000000..50078fd --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/content.blade.php @@ -0,0 +1,123 @@ +@props([ + 'title' => null, + 'description' => null, + 'author' => null, + 'date' => null, + 'category' => null, + 'image' => null, + 'backLink' => null, + 'backLabel' => 'Back', +]) + + + +
+ + + + + + + +
+ + + @if($title) +
+ + + +
+ + @if($backLink) + + + {{ $backLabel }} + + @endif + + @if($category) +
+ + {{ $category }} + +
+ @endif + +

+ {{ $title }} +

+ + @if($description) +

+ {{ $description }} +

+ @endif + + @if($author || $date) +
+ @if($author) +
+
+ {{ substr($author, 0, 1) }} +
+ {{ $author }} +
+ @endif + @if($author && $date) + · + @endif + @if($date) + + @endif +
+ @endif +
+ + @if($image) +
+
+ {{ $title }} +
+
+ @endif +
+ @endif + + +
+
+
+ {{ $slot }} +
+
+
+ + + @isset($after) +
+
+ {{ $after }} +
+
+ @endisset + +
+ + + +
+
diff --git a/src/Core/Front/Components/View/Blade/layouts/focused.blade.php b/src/Core/Front/Components/View/Blade/layouts/focused.blade.php new file mode 100644 index 0000000..d6dcc28 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/focused.blade.php @@ -0,0 +1,71 @@ +@props([ + 'title' => null, + 'description' => null, + 'step' => null, + 'totalSteps' => null, + 'showProgress' => false, +]) + + + +
+ + + + +
+ + + @if($showProgress && $step && $totalSteps) +
+
+
+ Step {{ $step }} of {{ $totalSteps }} + {{ round(($step / $totalSteps) * 100) }}% complete +
+
+
+
+
+
+ @endif + +
+
+ + + @if($title) +
+

+ {{ $title }} +

+ @if($description) +

+ {{ $description }} +

+ @endif +
+ @endif + + +
+ {{ $slot }} +
+ + + @isset($helper) +
+ {{ $helper }} +
+ @endisset + +
+
+ +
+ + + +
+
diff --git a/src/Core/Front/Components/View/Blade/layouts/minimal.blade.php b/src/Core/Front/Components/View/Blade/layouts/minimal.blade.php new file mode 100644 index 0000000..1605d0f --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/minimal.blade.php @@ -0,0 +1,25 @@ + + + + + + + + {{ $title ?? config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + + +
+
+
+
+ + {{ $slot }} + + @livewireScripts + + + diff --git a/src/Core/Front/Components/View/Blade/layouts/partials/base.blade.php b/src/Core/Front/Components/View/Blade/layouts/partials/base.blade.php new file mode 100644 index 0000000..809b03d --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/partials/base.blade.php @@ -0,0 +1,140 @@ +@props([ + 'title' => null, + 'description' => null, + 'ogImage' => null, + 'ogType' => 'website', + 'particles' => false, +]) + +@php + $appName = config('core.app.name', 'Core PHP'); + $appTagline = config('core.app.tagline', 'Modular Monolith Framework'); + $defaultDescription = config('core.app.description', "{$appName} - {$appTagline}"); + $contactEmail = config('core.contact.email', 'hello@' . config('core.domain.base', 'core.test')); + + $pageTitle = $title ? $title . ' - ' . $appName : $appName . ' - ' . $appTagline; + $pageDescription = $description ?? $defaultDescription; + $pageOgImage = $ogImage ?? asset('images/og-default.jpg'); + $pageUrl = url()->current(); +@endphp + + + + + + + + + {{ $pageTitle }} + + + + + + + + + + + + + + + + + + + + + + + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + @fluxAppearance + + {{ $head ?? '' }} + + @stack('styles') + + + + + Skip to main content + + +@if($particles) + + +@endif + +{{ $slot }} + + +@include('hub::admin.components.developer-bar') + + +@fluxScripts + +{{ $scripts ?? '' }} + +@stack('scripts') + + + diff --git a/src/Core/Front/Components/View/Blade/layouts/partials/fonts-inline.blade.php b/src/Core/Front/Components/View/Blade/layouts/partials/fonts-inline.blade.php new file mode 100644 index 0000000..250e895 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/partials/fonts-inline.blade.php @@ -0,0 +1,10 @@ +{{-- Self-hosted Inter variable font (inline for standalone pages) --}} + diff --git a/src/Core/Front/Components/View/Blade/layouts/partials/fonts.blade.php b/src/Core/Front/Components/View/Blade/layouts/partials/fonts.blade.php new file mode 100644 index 0000000..0dd4d23 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/partials/fonts.blade.php @@ -0,0 +1,10 @@ +{{-- Self-hosted Inter variable font --}} + diff --git a/src/Core/Front/Components/View/Blade/layouts/partials/footer.blade.php b/src/Core/Front/Components/View/Blade/layouts/partials/footer.blade.php new file mode 100644 index 0000000..5da12e0 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/partials/footer.blade.php @@ -0,0 +1,139 @@ +@props([ + 'minimal' => false, +]) + +@php + $appName = config('core.app.name', __('core::core.brand.name')); + $appLogo = config('core.app.logo', '/images/logo.svg'); + $appIcon = config('core.app.icon', '/images/icon.svg'); + $socialTwitter = config('core.social.twitter'); + $socialGithub = config('core.social.github'); +@endphp + +@if($minimal) + + +@else + + +@endif diff --git a/src/Core/Front/Components/View/Blade/layouts/partials/header.blade.php b/src/Core/Front/Components/View/Blade/layouts/partials/header.blade.php new file mode 100644 index 0000000..2946588 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/partials/header.blade.php @@ -0,0 +1,362 @@ +@props([ + 'minimal' => false, + 'transparent' => false, +]) + +
+
+
+ + + + + @unless($minimal) + + + + + + @auth + + + Dashboard + + + + + Hub Home + + + + + Services + + +
+ + Bio Pages +
+
+ + +
+ + Scheduling +
+
+ + +
+ + Coming Soon +
+
+ + +
+ + Coming Soon +
+
+ + +
+ + Coming Soon +
+
+ + +
+ + Coming Soon +
+
+ + + + + Profile Settings + +
+
+ + + Logout + + @else + + Login + + + {{-- VI_DONE: waitlist CTA, queue-based early access, 50% launch bonus --}} + + Get early access + + @endauth +
+ + +
+ + + + +
+ @else + + + @endunless + +
+
+
diff --git a/src/Core/Front/Components/View/Blade/layouts/sidebar-left.blade.php b/src/Core/Front/Components/View/Blade/layouts/sidebar-left.blade.php new file mode 100644 index 0000000..e2d16cb --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/sidebar-left.blade.php @@ -0,0 +1,70 @@ +@props([ + 'title' => null, + 'description' => null, +]) + + + +
+ + + + + + + +
+
+ + + @if($title) +
+

+ {{ $title }} +

+ @if($description) +

+ {{ $description }} +

+ @endif +
+ @endif + + +
+ + + + + +
+ {{ $slot }} +
+ +
+
+
+ + + +
+
diff --git a/src/Core/Front/Components/View/Blade/layouts/sidebar-right.blade.php b/src/Core/Front/Components/View/Blade/layouts/sidebar-right.blade.php new file mode 100644 index 0000000..fb4be46 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/sidebar-right.blade.php @@ -0,0 +1,124 @@ +@props([ + 'title' => null, + 'description' => null, + 'backLink' => null, + 'backLabel' => 'Back', +]) + + + + + + + +
+ + + + + + + +
+
+ + + @if($title) +
+ @if($backLink) + + + {{ $backLabel }} + + @endif + +

+ {{ $title }} +

+ @if($description) +

+ {{ $description }} +

+ @endif +
+ @endif + + +
+ + +
+
+ {{ $slot }} +
+
+ + + @isset($toc) + + @endisset + +
+
+
+ + + +
+ + + + +
diff --git a/src/Core/Front/Components/View/Blade/layouts/workspace.blade.php b/src/Core/Front/Components/View/Blade/layouts/workspace.blade.php new file mode 100644 index 0000000..274b9e7 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/layouts/workspace.blade.php @@ -0,0 +1,137 @@ +@props(['title' => null, 'workspace' => []]) + +@php + $appName = config('core.app.name', 'Core PHP'); + $baseDomain = config('core.domain.base', 'core.test'); + $hubUrl = 'https://hub.' . $baseDomain; + $wsName = $workspace['name'] ?? $appName; + $wsColor = $workspace['color'] ?? 'violet'; + $wsIcon = $workspace['icon'] ?? 'globe'; + $wsSlug = $workspace['slug'] ?? 'main'; +@endphp + + + + + + + + + {{ $title ?? $wsName . ' | ' . $appName }} + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + @fluxAppearance + + + + +
+ + +
+
+
+ + + + + + + + + + +
+
+
+ + +
+ {{ $slot }} +
+ + + + +
+ + + @fluxScripts + + diff --git a/src/Core/Front/Components/View/Blade/main.blade.php b/src/Core/Front/Components/View/Blade/main.blade.php new file mode 100644 index 0000000..7ca7366 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/main.blade.php @@ -0,0 +1,7 @@ +@props([ + 'container' => false, // Apply max-width container +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/menu.blade.php b/src/Core/Front/Components/View/Blade/menu.blade.php new file mode 100644 index 0000000..a14cf44 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/menu.blade.php @@ -0,0 +1,7 @@ +@props([ + 'keepOpen' => false, // Keep menu open after selection +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/menu/checkbox.blade.php b/src/Core/Front/Components/View/Blade/menu/checkbox.blade.php new file mode 100644 index 0000000..cf52655 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/menu/checkbox.blade.php @@ -0,0 +1,9 @@ +@props([ + 'wire:model' => null, + 'value' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/menu/group.blade.php b/src/Core/Front/Components/View/Blade/menu/group.blade.php new file mode 100644 index 0000000..fb555ea --- /dev/null +++ b/src/Core/Front/Components/View/Blade/menu/group.blade.php @@ -0,0 +1,7 @@ +@props([ + 'heading' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/menu/item.blade.php b/src/Core/Front/Components/View/Blade/menu/item.blade.php new file mode 100644 index 0000000..267079a --- /dev/null +++ b/src/Core/Front/Components/View/Blade/menu/item.blade.php @@ -0,0 +1,16 @@ +@props([ + 'href' => null, // URL for link items + 'icon' => null, // Icon name (left side) + 'iconTrailing' => null, // Icon name (right side) + 'iconVariant' => null, // outline, solid, mini, micro + 'kbd' => null, // Keyboard shortcut hint + 'suffix' => null, // Suffix text + 'variant' => null, // default, danger + 'disabled' => false, + 'keepOpen' => false, + 'wire:click' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/menu/radio.blade.php b/src/Core/Front/Components/View/Blade/menu/radio.blade.php new file mode 100644 index 0000000..ced3be1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/menu/radio.blade.php @@ -0,0 +1,9 @@ +@props([ + 'wire:model' => null, + 'value' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/menu/separator.blade.php b/src/Core/Front/Components/View/Blade/menu/separator.blade.php new file mode 100644 index 0000000..a50cbab --- /dev/null +++ b/src/Core/Front/Components/View/Blade/menu/separator.blade.php @@ -0,0 +1 @@ + diff --git a/src/Core/Front/Components/View/Blade/menu/submenu.blade.php b/src/Core/Front/Components/View/Blade/menu/submenu.blade.php new file mode 100644 index 0000000..a839eaf --- /dev/null +++ b/src/Core/Front/Components/View/Blade/menu/submenu.blade.php @@ -0,0 +1,7 @@ +@props([ + 'heading' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/modal.blade.php b/src/Core/Front/Components/View/Blade/modal.blade.php new file mode 100644 index 0000000..0035de2 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/modal.blade.php @@ -0,0 +1,2 @@ +{{-- Core Modal - Thin wrapper around flux:modal. Props: name, maxWidth (sm|md|lg|xl|2xl), variant (default|flyout), position (left|right), closeable --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/navbar.blade.php b/src/Core/Front/Components/View/Blade/navbar.blade.php new file mode 100644 index 0000000..e7be1c4 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/navbar.blade.php @@ -0,0 +1,7 @@ +@props([ + 'class' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/navbar/item.blade.php b/src/Core/Front/Components/View/Blade/navbar/item.blade.php new file mode 100644 index 0000000..698acba --- /dev/null +++ b/src/Core/Front/Components/View/Blade/navbar/item.blade.php @@ -0,0 +1,14 @@ +@props([ + 'href' => null, // URL + 'current' => null, // boolean, auto-detected if null + 'icon' => null, // Icon name (left side) + 'iconTrailing' => null, // Icon name (right side) + 'badge' => null, // Badge text/slot + 'badgeColor' => null, // Badge colour + 'badgeVariant' => null, // solid, outline + 'wire:navigate' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/navlist.blade.php b/src/Core/Front/Components/View/Blade/navlist.blade.php new file mode 100644 index 0000000..5d7780f --- /dev/null +++ b/src/Core/Front/Components/View/Blade/navlist.blade.php @@ -0,0 +1,23 @@ +@props([ + 'variant' => null, // outline + 'items' => [], // [{label, href?, action?, current?, icon?, badge?}] +]) + +except('items') }}> + @if(count($items) > 0) + @foreach($items as $item) + + {{ $item['label'] ?? '' }} + + @endforeach + @else + {{ $slot }} + @endif + diff --git a/src/Core/Front/Components/View/Blade/navlist/group.blade.php b/src/Core/Front/Components/View/Blade/navlist/group.blade.php new file mode 100644 index 0000000..60040bc --- /dev/null +++ b/src/Core/Front/Components/View/Blade/navlist/group.blade.php @@ -0,0 +1,9 @@ +@props([ + 'heading' => null, // Group heading text + 'expandable' => false, // Can be expanded/collapsed + 'expanded' => false, // Initial expanded state +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/navlist/item.blade.php b/src/Core/Front/Components/View/Blade/navlist/item.blade.php new file mode 100644 index 0000000..4395803 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/navlist/item.blade.php @@ -0,0 +1,11 @@ +@props([ + 'href' => null, // Link URL + 'icon' => null, // Icon name + 'badge' => null, // Badge text/count + 'current' => false, // Active state + 'wire:navigate' => null, // Livewire SPA navigation +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/navmenu.blade.php b/src/Core/Front/Components/View/Blade/navmenu.blade.php new file mode 100644 index 0000000..d096eae --- /dev/null +++ b/src/Core/Front/Components/View/Blade/navmenu.blade.php @@ -0,0 +1,8 @@ +@props([ + 'position' => null, // top, right, bottom, left + 'align' => null, // start, center, end +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/pillbox.blade.php b/src/Core/Front/Components/View/Blade/pillbox.blade.php new file mode 100644 index 0000000..85ec133 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/pillbox.blade.php @@ -0,0 +1,3 @@ +{{-- Core Pillbox - Flux Pro component. Props: placeholder, label, description, size, searchable, disabled, invalid, multiple --}} +@php(\Core\Pro::requireFluxPro('core:pillbox')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/pillbox/create.blade.php b/src/Core/Front/Components/View/Blade/pillbox/create.blade.php new file mode 100644 index 0000000..2419e69 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/pillbox/create.blade.php @@ -0,0 +1,7 @@ +@props([ + 'minLength' => null, // min-length +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/pillbox/empty.blade.php b/src/Core/Front/Components/View/Blade/pillbox/empty.blade.php new file mode 100644 index 0000000..363ddb9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/pillbox/empty.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/pillbox/input.blade.php b/src/Core/Front/Components/View/Blade/pillbox/input.blade.php new file mode 100644 index 0000000..f32cca4 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/pillbox/input.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/pillbox/option.blade.php b/src/Core/Front/Components/View/Blade/pillbox/option.blade.php new file mode 100644 index 0000000..bef80df --- /dev/null +++ b/src/Core/Front/Components/View/Blade/pillbox/option.blade.php @@ -0,0 +1,2 @@ +{{-- Core Pillbox Option - Thin wrapper around flux:pillbox.option. Props: value, disabled --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/pillbox/search.blade.php b/src/Core/Front/Components/View/Blade/pillbox/search.blade.php new file mode 100644 index 0000000..c281f89 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/pillbox/search.blade.php @@ -0,0 +1,7 @@ +@props([ + 'placeholder' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/pillbox/trigger.blade.php b/src/Core/Front/Components/View/Blade/pillbox/trigger.blade.php new file mode 100644 index 0000000..484b0db --- /dev/null +++ b/src/Core/Front/Components/View/Blade/pillbox/trigger.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/popover.blade.php b/src/Core/Front/Components/View/Blade/popover.blade.php new file mode 100644 index 0000000..b6cd26f --- /dev/null +++ b/src/Core/Front/Components/View/Blade/popover.blade.php @@ -0,0 +1,10 @@ +@props([ + 'position' => null, // top, bottom, left, right + 'align' => null, // start, center, end + 'offset' => null, // Offset from trigger + 'trigger' => null, // click, hover +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/radio.blade.php b/src/Core/Front/Components/View/Blade/radio.blade.php new file mode 100644 index 0000000..2700627 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/radio.blade.php @@ -0,0 +1,2 @@ +{{-- Core Radio - Thin wrapper around flux:radio. Props: label, description, value, disabled, checked --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/radio/group.blade.php b/src/Core/Front/Components/View/Blade/radio/group.blade.php new file mode 100644 index 0000000..a21d868 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/radio/group.blade.php @@ -0,0 +1,11 @@ +@props([ + 'label' => null, // Group label + 'description' => null, // Help text + 'variant' => null, // cards, segmented + 'wire:model' => null, + 'wire:model.live' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/select.blade.php b/src/Core/Front/Components/View/Blade/select.blade.php new file mode 100644 index 0000000..616d95c --- /dev/null +++ b/src/Core/Front/Components/View/Blade/select.blade.php @@ -0,0 +1,2 @@ +{{-- Core Select - Thin wrapper around flux:select. Props: label, description, placeholder, variant, size, disabled, invalid, multiple, searchable, clearable, filter --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/select/option.blade.php b/src/Core/Front/Components/View/Blade/select/option.blade.php new file mode 100644 index 0000000..389fdd6 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/select/option.blade.php @@ -0,0 +1,8 @@ +@props([ + 'value' => null, + 'disabled' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/separator.blade.php b/src/Core/Front/Components/View/Blade/separator.blade.php new file mode 100644 index 0000000..d3035e4 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/separator.blade.php @@ -0,0 +1,6 @@ +@props([ + 'vertical' => false, // Vertical orientation + 'text' => null, // Text in the middle of separator +]) + + diff --git a/src/Core/Front/Components/View/Blade/slider.blade.php b/src/Core/Front/Components/View/Blade/slider.blade.php new file mode 100644 index 0000000..e1f20a9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/slider.blade.php @@ -0,0 +1,3 @@ +{{-- Core Slider - Flux Pro component. Props: range, min, max, step, big-step, min-steps-between --}} +@php(\Core\Pro::requireFluxPro('core:slider')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/slider/tick.blade.php b/src/Core/Front/Components/View/Blade/slider/tick.blade.php new file mode 100644 index 0000000..59dcc42 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/slider/tick.blade.php @@ -0,0 +1,7 @@ +@props([ + 'value' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/subheading.blade.php b/src/Core/Front/Components/View/Blade/subheading.blade.php new file mode 100644 index 0000000..b79d5ac --- /dev/null +++ b/src/Core/Front/Components/View/Blade/subheading.blade.php @@ -0,0 +1,8 @@ +@props([ + 'level' => null, // 1, 2, 3, 4, 5, 6 (renders h1-h6) + 'size' => null, // xs, sm, base, lg, xl, 2xl +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/switch.blade.php b/src/Core/Front/Components/View/Blade/switch.blade.php new file mode 100644 index 0000000..1e210c3 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/switch.blade.php @@ -0,0 +1,10 @@ +@props([ + 'name' => null, // Switch name attribute + 'label' => null, // Label text + 'description' => null, // Help text + 'disabled' => false, + 'wire:model' => null, // Livewire binding + 'wire:model.live' => null, +]) + + diff --git a/src/Core/Front/Components/View/Blade/tab.blade.php b/src/Core/Front/Components/View/Blade/tab.blade.php new file mode 100644 index 0000000..b3c030f --- /dev/null +++ b/src/Core/Front/Components/View/Blade/tab.blade.php @@ -0,0 +1,11 @@ +{{-- Core Tab - Thin wrapper around flux:tab with Font Awesome icon support --}} +@props([ + 'icon' => null, +]) + +except('icon') }}> + @if($icon) + + @endif + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/tab/group.blade.php b/src/Core/Front/Components/View/Blade/tab/group.blade.php new file mode 100644 index 0000000..cd5d370 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/tab/group.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/tab/panel.blade.php b/src/Core/Front/Components/View/Blade/tab/panel.blade.php new file mode 100644 index 0000000..203b617 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/tab/panel.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/table.blade.php b/src/Core/Front/Components/View/Blade/table.blade.php new file mode 100644 index 0000000..79a10ef --- /dev/null +++ b/src/Core/Front/Components/View/Blade/table.blade.php @@ -0,0 +1,8 @@ +@props([ + 'paginate' => null, // Laravel paginator instance + 'containerClass' => null, // CSS classes for container +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/table/cell.blade.php b/src/Core/Front/Components/View/Blade/table/cell.blade.php new file mode 100644 index 0000000..ba0578c --- /dev/null +++ b/src/Core/Front/Components/View/Blade/table/cell.blade.php @@ -0,0 +1,9 @@ +@props([ + 'align' => null, // start, center, end + 'variant' => null, // default, strong + 'sticky' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/table/column.blade.php b/src/Core/Front/Components/View/Blade/table/column.blade.php new file mode 100644 index 0000000..1328dd6 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/table/column.blade.php @@ -0,0 +1,11 @@ +@props([ + 'align' => null, // start, center, end + 'sortable' => false, + 'sorted' => false, + 'direction' => null, // asc, desc + 'sticky' => false, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/table/columns.blade.php b/src/Core/Front/Components/View/Blade/table/columns.blade.php new file mode 100644 index 0000000..6dfd0de --- /dev/null +++ b/src/Core/Front/Components/View/Blade/table/columns.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/table/row.blade.php b/src/Core/Front/Components/View/Blade/table/row.blade.php new file mode 100644 index 0000000..4c26dd7 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/table/row.blade.php @@ -0,0 +1,7 @@ +@props([ + 'wire:key' => null, +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/table/rows.blade.php b/src/Core/Front/Components/View/Blade/table/rows.blade.php new file mode 100644 index 0000000..9d55e05 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/table/rows.blade.php @@ -0,0 +1,3 @@ + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/tabs.blade.php b/src/Core/Front/Components/View/Blade/tabs.blade.php new file mode 100644 index 0000000..34bf230 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/tabs.blade.php @@ -0,0 +1,2 @@ +{{-- Core Tabs - Thin wrapper around flux:tabs. Props: variant (segmented|pills), wire:model --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/text.blade.php b/src/Core/Front/Components/View/Blade/text.blade.php new file mode 100644 index 0000000..37995d1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/text.blade.php @@ -0,0 +1,2 @@ +{{-- Core Text - Thin wrapper around flux:text. Props: size (xs|sm|base|lg|xl), dim --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/textarea.blade.php b/src/Core/Front/Components/View/Blade/textarea.blade.php new file mode 100644 index 0000000..0b1fed1 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/textarea.blade.php @@ -0,0 +1,2 @@ +{{-- Core Textarea - Thin wrapper around flux:textarea. Props: name, rows, placeholder, label, description, resize (none|vertical|horizontal|both), disabled, readonly, required, wire:model --}} +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/time-picker.blade.php b/src/Core/Front/Components/View/Blade/time-picker.blade.php new file mode 100644 index 0000000..7753ffa --- /dev/null +++ b/src/Core/Front/Components/View/Blade/time-picker.blade.php @@ -0,0 +1,3 @@ +{{-- Core Time Picker - Flux Pro component. Props: value, type, interval, min, max, label, placeholder, size, clearable, disabled, locale --}} +@php(\Core\Pro::requireFluxPro('core:time-picker')) +{{ $slot }} diff --git a/src/Core/Front/Components/View/Blade/tooltip.blade.php b/src/Core/Front/Components/View/Blade/tooltip.blade.php new file mode 100644 index 0000000..2a7fcd5 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/tooltip.blade.php @@ -0,0 +1,9 @@ +@props([ + 'content' => null, // Tooltip text + 'position' => null, // top, bottom, left, right + 'kbd' => null, // Keyboard shortcut hint +]) + + + {{ $slot }} + diff --git a/src/Core/Front/Components/View/Blade/web/home.blade.php b/src/Core/Front/Components/View/Blade/web/home.blade.php new file mode 100644 index 0000000..5f27e24 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/web/home.blade.php @@ -0,0 +1,78 @@ + + + +
+
+
+

+ + {{ $content['site']['name'] ?? $workspace->name }} + +

+ @if(isset($content['site']['description'])) +

+ {{ $content['site']['description'] }} +

+ @endif +
+ + + @if(!empty($content['featured_posts'])) + + + + @else +
+
+ +
+

No posts yet. Check back soon!

+
+ @endif +
+
+ +
diff --git a/src/Core/Front/Components/View/Blade/web/page.blade.php b/src/Core/Front/Components/View/Blade/web/page.blade.php new file mode 100644 index 0000000..ed2e5a9 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/web/page.blade.php @@ -0,0 +1,44 @@ + + +
+
+ + +
+

+ {!! $page['title']['rendered'] ?? 'Untitled' !!} +

+
+ + + @if(isset($page['_embedded']['wp:featuredmedia'][0])) +
+ {{ $page['title']['rendered'] ?? '' }} +
+ @endif + + +
+ {!! $page['content']['rendered'] ?? '' !!} +
+ +
+
+ +
diff --git a/src/Core/Front/Components/View/Blade/web/waitlist.blade.php b/src/Core/Front/Components/View/Blade/web/waitlist.blade.php new file mode 100644 index 0000000..5589753 --- /dev/null +++ b/src/Core/Front/Components/View/Blade/web/waitlist.blade.php @@ -0,0 +1,152 @@ +@php + $meta = [ + 'title' => ($workspace?->name ?? 'Host UK') . ' - Coming Soon', + 'description' => $workspace?->description ?? 'I\'m working on something amazing. Join the waitlist to be notified when it launches.', + 'url' => request()->url(), + ]; +@endphp + + + +
+
+
+ + +
+
+
+ + + + + Coming Soon +
+
+
+ + + @if($workspace?->icon) +
+ +
+ @else +
+ Host UK +
+ @endif + + +

+ + {{ $workspace?->name ?? 'Something Amazing' }} + +

+ + +

+ {{ $workspace?->description ?? 'I\'m working on something special. Join the waitlist to be the first to know when it launches.' }} +

+ + + @if($subscribed) +
+ + You're on the list! I'll notify you when it launches. +
+ @else + +
+
+
+ +
+ @csrf + + + + +
+
+
+
+ +
+ +
+ +
+
+ + @error('email') +

{{ $message }}

+ @enderror +
+ +
+
+
+ @endif + + +
+
+ @for($i = 1; $i <= 5; $i++) +
+ +
+ @endfor +
+

+ Join hundreds of others waiting for launch +

+
+ +
+
+
+ + +
+ +
+
+

What to expect

+
+
+
+ +
+

Fast Performance

+

Blazing fast load times with edge caching across 96+ locations worldwide.

+
+
+
+ +
+

Secure & Private

+

GDPR compliant with EU-hosted infrastructure. Your data stays safe.

+
+
+
+ +
+

Creator Friendly

+

Built for content creators who need reliable, adult-friendly hosting.

+
+
+
+
+ +
diff --git a/src/Core/Front/Controller.php b/src/Core/Front/Controller.php new file mode 100644 index 0000000..aa2e58b --- /dev/null +++ b/src/Core/Front/Controller.php @@ -0,0 +1,17 @@ +h('') + ->c('
Main content
') + ->f('
Footer
'); + +echo $page; +``` + +## The Five Regions + +| Letter | Region | HTML Element | Purpose | +|--------|---------|--------------|---------| +| **H** | Header | `
` | Top navigation, branding | +| **L** | Left | `