Compare commits

..

No commits in common. "cf30f3e41a31a8188c236cedea9802d1a06987bf" and "0a22ea34cbc62beb7e9a4cd3a6e48fc7f4110791" have entirely different histories.

133 changed files with 4 additions and 6905 deletions

View file

@ -1,16 +0,0 @@
# EditorConfig helps maintain consistent coding styles
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.php]
indent_style = space
indent_size = 4
[*.{yml,yaml,json,md}]
indent_style = space
indent_size = 2

View file

@ -1,17 +0,0 @@
APP_NAME=Phred App
APP_ENV=local
APP_DEBUG=true
APP_TIMEZONE=UTC
API_FORMAT=rest
DB_DRIVER=sqlite
DB_DATABASE=database/database.sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=
ORM_DRIVER=pairity
TEMPLATE_DRIVER=eyrie
FLAGS_DRIVER=flagpole
TEST_RUNNER=codeception

10
.gitattributes vendored
View file

@ -1,10 +0,0 @@
# Exclude dev files from exported archives
/.github export-ignore
/tests export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/phpstan.neon.dist export-ignore
/.php-cs-fixer.php export-ignore
/MILESTONES.md export-ignore
/README.md export-ignore

View file

@ -1,56 +0,0 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ["8.1", "8.2", "8.3"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, intl, json
tools: composer:v2
coverage: none
- name: Validate composer.json
run: composer validate --no-check-publish --strict
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: PHP CS Fixer (dry-run)
run: vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --verbose src
- name: PHPStan
run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G
- name: Codeception (if configured)
if: hashFiles('**/codeception.yml') != ''
run: vendor/bin/codecept run --verbosity 1

59
.gitignore vendored
View file

@ -2,9 +2,9 @@
composer.phar
/vendor/
# Template policy: do not commit composer.lock in this template repo
# (apps generated from this template should commit THEIR lock file)
composer.lock
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
# composer.lock
# ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
@ -85,56 +85,3 @@ fabric.properties
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# --- Additional ignores (Phred project) ---
# Environment files (keep .env.example tracked)
.env
.env.local
.env.*.local
# IDE/editor folders
.idea/
.vscode/
# OS files
.DS_Store
Thumbs.db
# Editor swap/backup files
*.swp
*.swo
*~
# Tool caches and build artifacts
.php-cs-fixer.cache
.phpunit.result.cache
.cache/
coverage/
.coverage/
build/
var/
tmp/
# Scaffolding output created during local development of this template
# These are part of generated apps, not this template repo
/public/
/bootstrap/
/routes/
/resources/
/modules/
/storage/*
!/storage/.gitkeep
/config/
/console/
# Local assistant/session preferences (developer-specific)
.junie/
# Codeception outputs
tests/_output/
tests/_support/_generated/
/.env
/.phpunit.cache
/.php-cs-fixer.cache

View file

@ -1,268 +0,0 @@
# Phred Framework Milestones
[← Back to README](./README.md) | [SPECS.md](./SPECS.md)
Phred supports REST and JSON:API via env setting; batteries-included defaults, swappable components.
[↑ Back to Top](#table-of-contents)
## Table of Contents
- [~~M0 — Project bootstrap (repo readiness)~~](#m0-project-bootstrap-repo-readiness)
- [~~M1 — Core HTTP kernel and routing~~](#m1-core-http-kernel-and-routing)
- [~~M2 — Configuration and environment~~](#m2-configuration-and-environment)
- [~~M3 — API formats and content negotiation~~](#m3-api-formats-and-content-negotiation)
- [~~M4 — Error handling and problem details~~](#m4-error-handling-and-problem-details)
- [~~M5 — Dependency Injection and Service Providers~~](#m5-dependency-injection-and-service-providers)
- [~~M6 — MVC: Controllers, Views, Templates~~](#m6-mvc-controllers-views-templates)
- [~~M7 — Modules (Djangostyle app structure)~~](#m7-modules-django-style-app-structure)
- [~~M8 — Database access, migrations, and seeds~~](#m8-database-access-migrations-and-seeds)
- [~~M9 — CLI (phred) and scaffolding~~](#m9-cli-phred-and-scaffolding)
- [~~M10 — Security middleware and auth primitives~~](#m10-security-middleware-and-auth-primitives)
- [~~M11 — Logging, HTTP client, and filesystem~~](#m11-logging-http-client-and-filesystem)
- [~~M12 — Serialization/validation utilities and pagination~~](#m12-serialization-validation-utilities-and-pagination)
- [M13 — OpenAPI and documentation](#m13-openapi-and-documentation)
- [M14 — Testing, quality, and DX](#m14-testing-quality-and-dx)
- [M15 — Caching and performance (optional default)](#m15-caching-and-performance-optional-default)
- [M16 — Production hardening and deployment](#m16-production-hardening-and-deployment)
- [M17 — JSON:API enhancements (optional package)](#m17-json-api-enhancements-optional-package)
- [M18 — Examples and starter template](#m18-examples-and-starter-template)
- [M19 — Documentation site](#m19-documentation-site)
- [M20 — Dynamic Command Help](#m20-dynamic-command-help)
- [M21 — Governance and roadmap tracking](#m21-governance-and-roadmap-tracking)
- [Notes on sequencing and parallelization](#notes-on-sequencing-and-parallelization)
## ~~M0 — Project bootstrap (repo readiness)~~
* ~~Tasks:~~
* ~~Finalize `composer.json` (namespaces, scripts, suggests) and `LICENSE`.~~
* ~~Add `.editorconfig`, `.gitattributes`, `.gitignore`, example `.env.example`.~~
* ~~Set up CI (lint, static analysis, unit tests) and basic build badge.~~
* ~~Acceptance:~~
* ~~Fresh clone installs (without running suggested packages) and passes linters/analysis/tests.~~
[↑ Back to Top](#table-of-contents)
## ~~M1 — Core HTTP kernel and routing~~
* ~~Tasks:~~
* ~~Implement the HTTP kernel: `PSR-15` pipeline via `Relay`.~~
* ~~Wire `nyholm/psr7(-server)` factories and server request creation.~~
* ~~Integrate `nikic/fast-route` with a RouteCollector and dispatcher.~~
* ~~Define route → controller resolution (invokable controllers).~~
* ~~Add minimal app bootstrap (front controller) and DI container wiring (`PHP-DI`).~~
* ~~Addendum: Route groups (prefix only) via `Router::group()`~~
* ~~Acceptance:~~
* ~~Sample route returning a JSON 200 via controller.~~
* ~~Controllers are invokable (`__invoke(Request)`), one route per controller.~~
* ~~Route groups (prefix only) work and are tested.~~
[↑ Back to Top](#table-of-contents)
## ~~M2 — Configuration and environment~~
* ~~Tasks:~~
* ~~Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`.~~
* ~~Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).~~
* ~~Acceptance:~~
* ~~App reads config from `.env`; unit test demonstrates override behavior.~~
[↑ Back to Top](#table-of-contents)
## ~~M3 — API formats and content negotiation~~
* ~~Tasks:~~
* ~~Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.~~
* ~~Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.~~
* ~~Provide developerfacing helpers for common responses (`ok`, `created`, `error`).~~
* ~~Acceptance:~~
* ~~Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.~~
[↑ Back to Top](#table-of-contents)
## ~~M4 — Error handling and problem details~~
* ~~Tasks:~~
* ~~Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.~~
* ~~Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).~~
* ~~Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.~~
* ~~Acceptance:~~
* ~~Throwing an exception yields a standardscompliant error response; debug mode shows Whoops page.~~
[↑ Back to Top](#table-of-contents)
## ~~M5 — Dependency Injection and Service Providers~~
* ~~Tasks:~~
* ~~Define Service Provider interface and lifecycle (register, boot).~~
* ~~Module discovery loads providers in order (core → app → module).~~
* ~~Add examples for registering controllers, services, config, and routes via providers.~~
* ~~Define contracts: `Template\Contracts\RendererInterface`, `Orm\Contracts\*`, `Flags\Contracts\FeatureFlagClientInterface`, `Testing\Contracts\TestRunnerInterface`.~~
* ~~Define config/env keys for driver selection (e.g., `TEMPLATE_DRIVER`, `ORM_DRIVER`, `FLAGS_DRIVER`, `TEST_RUNNER`).~~
* ~~Provide “default adapter” Service Providers for the shipped packages and document swap procedure.~~
* ~~Acceptance:~~
* ~~Providers can contribute bindings and routes; order is deterministic and tested.~~
* ~~Drivers can be switched via `.env`/config without changing controllers/services; example provider route covered by tests.~~
[↑ Back to Top](#table-of-contents)
## ~~M6 — MVC: Controllers, Views, Templates~~
* ~~Tasks:~~
* ~~Controller base class and conventions (request/response helpers).~~
* ~~View layer (data preparation) with `getphred/eyrie` template engine integration.~~
* ~~Template rendering helper: `$this->render(<template>, <data>)`.~~
* ~~Acceptance:~~
* ~~Example page rendered through View → Template; API coexists with fullsite rendering.~~
* ~~Rendering works via RendererInterface and can be swapped (e.g., Eyrie → Twig demo) with only configuration/provider changes.~~
[↑ Back to Top](#table-of-contents)
## ~~M7 — Modules (Djangostyle app structure)~~
* ~~Tasks:~~
* ~~Define module filesystem layout (Nested Controllers/Views/Services/Models/Templates/Routes/Tests).~~
* ~~Module loader: autoregister providers, routes, templates.~~
* ~~Namespacing and autoload guidance.~~
* ~~Core CLI: add `create:module <name>` command to scaffold a module with nested resources.~~
* ~~ORMagnostic module layout (to support Pairity DAO/DTO and Eloquent Active Record):~~
* ~~`Modules/<X>/Models/` — domain models (pure PHP, ORMneutral)~~
* ~~`Modules/<X>/Repositories/` — repository interfaces (DI targets for services)~~
* ~~`Modules/<X>/Persistence/Pairity/` — DAOs, DTOs, mappers, repository implementations~~
* ~~`Modules/<X>/Persistence/Eloquent/` — Eloquent models, scopes, repository implementations~~
* ~~`Modules/<X>/Database/Migrations/*` — canonical migrations for the module (no duplication per driver)~~
* ~~Acceptance:~~
* ~~Creating a module with the CLI makes it discoverable; routes/templates work without manual wiring.~~
* ~~Switching `ORM_DRIVER` between `pairity` and `eloquent` requires no changes to services/controllers; providers bind repository interfaces to driver implementations.~~
[↑ Back to Top](#table-of-contents)
## ~~M8 — Database access, migrations, and seeds~~
* ~~Tasks:~~
* ~~Integrate `getphred/pairity` for ORM/migrations/seeds.~~
* ~~Define config (`DB_*`), migration paths (app and modules), and seeder conventions.~~
* ~~CLI commands: `migrate`, `migration:rollback`, `seed`, `seed:rollback`.~~
* ~~All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).~~
* ~~Add `register:orm <driver>` command:~~
* ~~Verify or guide installation of the ORM driver package.~~
* ~~Update `.env` (`ORM_DRIVER=<driver>`) safely.~~
* ~~Create `modules/*/Persistence/<Driver>/` directories for existing modules.~~
* ~~Acceptance:~~
* ~~Running migrations modifies a test database; seeds populate sample data; CRUD demo works.~~
* ~~All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).~~
[↑ Back to Top](#table-of-contents)
## ~~M9 — CLI (phred) and scaffolding~~
* ~~Tasks:~~
* ~~Implement Symfony Console app in `bin/phred`.~~
* ~~Generators: `create:<module>:controller`, `create:<module>:model`, `create:<module>:migration`, `create:<module>:seed`, `create:<module>:test`, `create:<module>:view`.~~
* ~~Utility commands: `test[:<module>]`, `run`, `db:backup`, `db:restore`.~~
* ~~Acceptance:~~
* ~~Commands generate files with correct namespaces/paths and pass basic smoke tests.~~
[↑ Back to Top](#table-of-contents)
## ~~M10 — Security middleware and auth primitives~~
* ~~Tasks:~~
* ~~Add CORS, Secure Headers middlewares; optional CSRF for template routes.~~
* ~~JWT support (lcobucci/jwt) with simple token issue/verify service.~~
* ~~Configuration for CORS origins, headers, methods.~~
* ~~Bind FeatureFlagClientInterface with a default adapter (Flagpole); add small sample usage and env config.~~
* ~~Acceptance:~~
* ~~CORS preflight and secured endpoints behave as configured; JWTprotected route example works.~~
[↑ Back to Top](#table-of-contents)
## ~~M11 — Logging, HTTP client, and filesystem~~
* ~~Tasks:~~
* ~~Monolog setup with handlers and processors (request ID, memory, timing).~~
* ~~Guzzle PSR18 client exposure; DI binding for HTTP client interface.~~
* ~~Flysystem integration with local adapter; abstraction for storage disks.~~
* ~~Standardize all core service providers with robust driver validation (similar to OrmServiceProvider).~~
* ~~Opportunity Radar: Multiple storage disks, log channel management, and HTTP client middleware.~~
* ~~Opportunity Radar 2: Storage Disk "Cloud" Adapters (S3), Advanced HTTP Middleware (Circuit Breaker), and Log Alerting (Slack/Sentry).~~
* ~~Opportunity Radar 3: Githook Integration, Breadcrumb Automation, and TOC for MILESTONES.md.~~
* ~~Acceptance:~~
* ~~Logs include correlation IDs; sample outbound HTTP call via client; file upload/storage demo works.~~
[↑ Back to Top](#table-of-contents)
## ~~M12 — Serialization/validation utilities and pagination~~
* ~~Tasks:~~
* ~~REST default: Symfony Serializer normalizers/encoders; document extension points.~~
* ~~Add simple validation layer using `Phred\Http\Middleware\Middleware` base.~~
* ~~Pagination helpers (links/meta), REST and JSON:API compatible outputs.~~
* ~~URL extension negotiation: add XML support~~
* ~~Provide `XmlResponseFactory` (or encoder) and integrate with negotiation.~~
* ~~Enable `xml` in `URL_EXTENSION_WHITELIST` by default.~~
* ~~Opportunity Radar: Storage URL generation, Circuit Breaker persistence (PSR-16), and HTTP client profiling.~~
* ~~Acceptance:~~
* ~~Example endpoint validates input, returns 422 with details; paginated listing includes links/meta.~~
[↑ Back to Top](#table-of-contents)
## M13 — OpenAPI and documentation
* Tasks:
* Integrate `zircote/swagger-php` annotations.
* CLI/task to generate OpenAPI JSON; optional serve route and Redoc UI pointer.
* Document auth, pagination, error formats.
* Acceptance:
* Generated OpenAPI document validates; matches sample endpoints.
[↑ Back to Top](#table-of-contents)
## M14 — Testing, quality, and DX
* Tasks:
* Establish testing structure with Codeception (unit, integration, API suites).
* Add fixtures/factories via Faker for examples.
* PHPStan level selection and baseline; code style via php-cs-fixer ruleset.
* Precommit hooks (e.g., GrumPHP) or custom git hooks for staged files.
* Define TestRunnerInterface and a Codeception adapter; otherwise, state tests are run via Composer script only.
* Acceptance:
* `composer test` runs green across suites; static analysis passes.
* CLI tests run via TestRunnerInterface;
* CLI tests run green per module and across suites.
[↑ Back to Top](#table-of-contents)
## M15 — Caching and performance (optional default)
* Tasks:
* Provide `PSR-16` cache interface binding; suggest `symfony/cache` when enabled.
* Simple response caching middleware and ETag/LastModified helpers.
* Rate limiting middleware (token bucket) suggestion/integration point.
* Acceptance:
* Sample endpoint demonstrates cached responses and conditional requests.
[↑ Back to Top](#table-of-contents)
## M16 — Production hardening and deployment
* Tasks:
* Config for envs (dev/test/stage/prod), error verbosity, trusted proxies/hosts.
* Docker example, PHPFPM + Nginx config templates.
* Healthcheck endpoint, readiness/liveness probes.
* Acceptance:
* Containerized demo serves both API and template pages; healthchecks pass.
[↑ Back to Top](#table-of-contents)
## M17 — JSON:API enhancements (optional package)
* Tasks:
* If enabled, integrate `neomerx/json-api` fully: includes, sparse fieldsets, relationships, sorting, filtering, pagination params.
* Adapters/Schema providers per resource type.
* Acceptance:
* JSON:API conformance tests for selected endpoints pass; docs updated.
[↑ Back to Top](#table-of-contents)
## M18 — Examples and starter template
* Tasks:
* Create `examples/blog` module showcasing controllers, views, templates, ORM, auth, pagination, and both API formats.
* Ensure examples use the external stubs and module-specific CLI command conventions.
* Provide `composer create-project` skeleton template instructions.
* Acceptance:
* New users can scaffold a working app in minutes following README.
[↑ Back to Top](#table-of-contents)
## M19 — Documentation site
* Tasks:
* Build the official documentation site (getphred.com) using the Phred framework.
* Internal Documentation API: Design a "Documentation Module" that parses Markdown files from the repo to serve them dynamically.
* ~~Automated TOC Generation: Script to regenerate SPECS.md Table of Contents.~~
* Versioned docs and upgrade notes.
* Acceptance:
* Docs published; links in README; examples maintained.
[↑ Back to Top](#table-of-contents)
## M20 — Dynamic Command Help
* Tasks:
* Implement a `docs` command in `phred` (e.g., `php phred docs <command>`) that opens the relevant documentation page in the user's browser.
* Build the `docs` command logic after the documentation site (getphred.com) is functional.
* Acceptance:
* `php phred docs create:module` opens the correct URL in the default browser.
[↑ Back to Top](#table-of-contents)
## M21 — Governance and roadmap tracking
* Tasks:
* Define contribution guide, issue templates, RFC process for changes.
* Public roadmap (this milestone list) tracked as GitHub Projects/Issues.
* Acceptance:
* Contributors can propose features via RFC; roadmap is visible and updated.
[↑ Back to Top](#table-of-contents)
## Notes on sequencing and parallelization
* M0M4 are critical path for the HTTP core and should be completed sequentially.
* M5M8 can progress in parallel with M9 (CLI) once the kernel is stable.
* Optional tracks (M15, M17) can be deferred without blocking core usability.

View file

@ -1,94 +1,3 @@
# Phred
A PHP MVC framework intended for projects of all sizes, designed for both solo and team development.
## Requirements
* **PHP**: 8.2+
* **Web Server**: Apache/Nginx (recommended)
* **Package Manager**: Composer
## Installation
Install Phred via Composer:
```bash
composer create-project getphred/phred
```
## Getting Started
### Creating a Module
Phred uses a modular (Django-style) architecture. All your application logic lives inside modules.
To scaffold a new module:
```bash
php phred create:module Blog
```
This will create the module structure under `modules/Blog`, register the service provider, and mount the routes.
After creating a module, update your `composer.json` to include the new namespace:
```json
{
"autoload": {
"psr-4": {
"Project\\\\Modules\\\\Blog\\\\": "modules/Blog/"
}
}
}
```
Then run:
```bash
composer dump-autoload
```
### Running the Application
Start the local development server:
```bash
php phred run
```
The application will be available at `http://localhost:8000`.
## CLI Usage (phred)
The `phred` binary provides several utility and scaffolding commands.
### Generators
* `php phred create:module <name>` — Create a new module.
* `php phred create:command <name>` — Create a custom CLI command.
* `php phred create:<module>:controller <name>` — Create a controller.
* `php phred create:<module>:view <name>` — Create a view and template.
* `php phred create:<module>:model <name>` — Create a domain model.
* `php phred create:<module>:migration <name>` — Create a migration.
* `php phred create:<module>:seed <name>` — Create a database seeder.
* `php phred create:<module>:test <name>` — Create a test.
### Database
* `php phred migrate` — Run database migrations.
* `php phred migration:rollback` — Rollback migrations.
* `php phred seed` — Seed the database.
* `php phred db:backup` — Backup the database.
* `php phred db:restore` — Restore the database.
### Testing & Utilities
* `php phred test` — Run tests for the entire project.
* `php phred test:<module>` — Run tests for a specific module.
* `php phred list` — List all available commands.
## Technical Specifications
For detailed information on the framework architecture, service providers, configuration, and MVC components, please refer to:
👉 **[SPECS.md](./SPECS.md)** | [MILESTONES.md](./MILESTONES.md)
## License
Phred is open-source software licensed under the MIT license.
A PHP MVC framework with invokable controllers (Actions), Views are classes for data manipulation before rendering Templates, Models are partitioned between DAO and DTO objects. And Modular separation, similar to Django apps.

161
SPECS.md
View file

@ -1,161 +0,0 @@
# Phred Technical Specifications
[← Back to README](./README.md) | [MILESTONES.md](./MILESTONES.md)
## Table of Contents
- [1. Core Framework](#1-core-framework)
- [1.1 API Formats and Content Negotiation](#1-1-api-formats-and-content-negotiation)
- [1.2 Pluggability Model (M5)](#1-2-pluggability-model-m5)
- [1.3 URL Extension Negotiation](#1-3-url-extension-negotiation)
- [1.4 Dependencies](#1-4-dependencies)
- [2. MVC Components](#2-mvc-components)
- [2.1 Controllers](#2-1-controllers)
- [2.2 Views (M6)](#2-2-views-m6)
- [2.3 Services](#2-3-services)
- [3. Modular Architecture (M7)](#3-modular-architecture-m7)
- [3.1 Module Layout (ORM-Agnostic)](#3-1-module-layout-orm-agnostic)
- [3.2 Modular Separation](#3-2-modular-separation)
- [4. Infrastructure](#4-infrastructure)
- [4.1 Service Providers (M5)](#4-1-service-providers-m5)
- [4.2 Configuration and Environment](#4-2-configuration-and-environment)
- [4.3 Command Discovery](#4-3-command-discovery)
- [5. Technical Examples](#5-technical-examples)
- [5.1 Configuration Access](#5-1-configuration-access)
- [5.2 API Negotiation](#5-2-api-negotiation)
- [5.3 Route Group Inclusion](#5-3-route-group-inclusion)
This document outlines the technical specifications and architectural standards of the Phred framework.
## 1. Core Framework
### 1.1 API Formats and Content Negotiation
Phred supports two primary API formats:
* **Pragmatic REST (default)**: Plain JSON responses using RFC 7807 for error handling.
* **JSON:API**: Compliant with the JSON:API specification for documents and error objects.
Negotiation can be set globally via `.env`:
* `API_FORMAT=rest`
* `API_FORMAT=jsonapi`
Clients can override the format per request using the `Accept` header:
* `Accept: application/vnd.api+json` forces JSON:API.
* `Accept: application/xml` or `text/xml` (M12+).
### 1.2 Pluggability Model (M5)
The framework is fully pluggable. Core components depend on Phred contracts and PSRs; concrete implementations are provided by Service Providers.
* Providers implement `Phred\Support\Contracts\ServiceProviderInterface`.
* Lifecycle methods: `register(ContainerBuilder)` and `boot(Container)`.
* Loading order: Core → App → Modules (defined in `config/providers.php`).
**Primary Contracts:**
* `Template\Contracts\RendererInterface`
* `Orm\Contracts\ConnectionInterface`
* `Flags\Contracts\FeatureFlagClientInterface`
* `Testing\Contracts\TestRunnerInterface`
### 1.3 URL Extension Negotiation
Optional middleware that hints content negotiation based on URL suffix.
* Enabled via `URL_EXTENSION_NEGOTIATION=true` (default).
* Whitelist via `URL_EXTENSION_WHITELIST` (default: "json|xml|php|none").
* **Mappings:**
* `.json``application/json`
* `.xml``application/xml`
* `.php` or none → `text/html` (View convention)
### 1.4 Dependencies
Phred leverages several industry-standard packages:
* **Dependency Injection**: `php-di/php-di`
* **Static Analysis**: `phpstan/phpstan`
* **Code Style**: `friendsofphp/php-cs-fixer`
* **Logging**: `monolog/monolog`
* **Environment**: `vlucas/phpdotenv`
* **HTTP Client**: `guzzlehttp/guzzle`
* **CORS**: `middlewares/cors`
* **Authentication**: `lcobucci/jwt`
* **Feature Flags**: `getphred/flagpole`
* **ORM**: `getphred/pairity`
* **Template Engine**: `getphred/eyrie`
* **Router**: `nikic/fast-route`
* **PSR-7/15**: `relay/relay`, `nyholm/psr7`
## 2. MVC Components
### 2.1 Controllers
* **Invokable**: Controllers are "Actions" with a single entry point: `public function __invoke(Request $request)`.
* **Response Factories**: Injected via `Phred\Http\Contracts\ApiResponseFactoryInterface`.
* **Base Classes (M6)**:
* `Phred\Mvc\APIController`: For API-only endpoints.
* `Phred\Mvc\ViewController`: For HTML/Template endpoints.
### 2.2 Views (M6)
* Classes dedicated to data preparation before rendering.
* Extend `Phred\Mvc\View`.
* Methods:
* `transformData(array $data)`: Manipulate data before it hits the template.
* `defaultTemplate()`: Define the default template for the view.
* **Conventions**: Controllers call `renderView($view, $data, ?$templateOverride)`. If no override is provided, the View decides the template.
### 2.3 Services
* Business logic should reside in Service classes to keep controllers lean and models ORM-neutral.
## 3. Modular Architecture (M7)
Phred follows a Django-style modular structure where all user code lives inside modules.
### 3.1 Module Layout (ORM-Agnostic)
* `modules/<Module>/Controllers/`
* `modules/<Module>/Views/`
* `modules/<Module>/Templates/`
* `modules/<Module>/Services/`
* `modules/<Module>/Models/` (Domain models, pure PHP)
* `modules/<Module>/Repositories/` (Interfaces consumed by services)
* `modules/<Module>/Persistence/Pairity/` (Driver-specific implementations)
* `modules/<Module>/Persistence/Eloquent/` (Driver-specific implementations)
* `modules/<Module>/Database/Migrations/` (Canonical migrations)
* `modules/<Module>/Routes/web.php` and `api.php`
* `modules/<Module>/Providers/*ServiceProvider.php`
### 3.2 Modular Separation
Modules encapsulate their own:
* Models, Controllers, Views, Services, Migrations, Providers, Routes, Templates, and Tests.
## 4. Infrastructure
### 4.1 Service Providers (M5)
Providers are the glue of the framework.
* **Configuration**: `config/providers.php`
* **Route Registration**: Use `RouteRegistry::add(static function($collector, $router) { ... })`.
### 4.2 Configuration and Environment
* **Dotenv**: Loads `.env` from the project root.
* **Config Facade**: `Phred\Support\Config::get(<key>, <default>)`.
* **Precedence**: Environment variables > Config files (`config/*.php`) > Defaults.
* **Notation**: Supports both `UPPER_SNAKE` and `dot.notation`.
### 4.3 Command Discovery
* **Core Commands**: Discovered from `src/commands`.
* **User Commands**: Discovered from `console/commands`.
* Custom commands must return an instance of `Phred\Console\Command`.
## 5. Technical Examples
### 5.1 Configuration Access
```php
use Phred\Support\Config;
$env = Config::get('APP_ENV', 'local'); // environment check
$tz = Config::get('app.timezone', 'UTC'); // dotted notation from config/app.php
```
### 5.2 API Negotiation
The chosen format is stored on the request as `phred.api_format` and used by the `ApiResponseFactoryInterface` to determine the response shape.
### 5.3 Route Group Inclusion
```php
use Phred\Http\Routing\RouteGroups;
use Phred\Http\Router;
RouteGroups::include($router, '/prefix', function (Router $router) {
require __DIR__ . '/../path/to/routes.php';
});
```

View file

@ -1,80 +0,0 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
namespace {
// Ensure composer autoload is available whether this is run from repo or installed project
$autoloadPaths = [
__DIR__ . '/../vendor/autoload.php', // project root vendor
__DIR__ . '/../../autoload.php', // vendor/bin scenario
];
$autoloaded = false;
foreach ($autoloadPaths as $path) {
if (is_file($path)) {
require $path;
$autoloaded = true;
break;
}
}
if (!$autoloaded) {
fwrite(STDERR, "Unable to locate Composer autoload.\n");
exit(1);
}
$app = new \Symfony\Component\Console\Application('Phred', '0.1');
// Discover core commands bundled with Phred (moved under src/commands)
$coreDir = dirname(__DIR__) . '/src/commands';
$generators = [
'create:controller',
'create:view',
'create:model',
'create:migration',
'create:seed',
'create:test'
];
if (is_dir($coreDir)) {
foreach (glob($coreDir . '/*.php') as $file) {
/** @var \Phred\Console\Command $cmd */
$cmd = require $file;
if ($cmd instanceof \Phred\Console\Command) {
$app->add($cmd->toSymfony());
// If it's a generator, also register module-specific versions
if (in_array($cmd->getName(), $generators, true)) {
$modulesDir = getcwd() . '/modules';
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $module) {
if ($module === '.' || $module === '..' || !is_dir($modulesDir . '/' . $module)) {
continue;
}
// Create a module-specific command name: create:blog:controller
$moduleCmdName = str_replace('create:', 'create:' . strtolower($module) . ':', $cmd->getName());
// We need a fresh instance for each command name to avoid overwriting Symfony's command registry
$moduleCmd = require $file;
$moduleCmd->setName($moduleCmdName);
$app->add($moduleCmd->toSymfony());
}
}
}
}
}
}
// Discover user commands in console/commands
$userDir = getcwd() . '/console/commands';
if (is_dir($userDir)) {
foreach (glob($userDir . '/*.php') as $file) {
$cmd = require $file;
if ($cmd instanceof \Phred\Console\Command) {
$app->add($cmd->toSymfony());
}
}
}
// Run
$app->run();
}

View file

@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
/**
* Automatically generates a Table of Contents and injects breadcrumbs for Markdown files.
*/
function generateToc(string $filePath, string $name): void
{
if (!is_file($filePath)) {
echo "$name not found.\n";
return;
}
$content = file_get_contents($filePath);
$lines = explode("\n", $content);
$inToc = false;
$headers = [];
$bodyLines = [];
foreach ($lines as $line) {
if (trim($line) === '## Table of Contents') {
$inToc = true;
continue;
}
// We assume the TOC ends at the next header or double newline
if ($inToc && (str_starts_with($line, '## ') || (str_contains($content, 'This document outlines') && str_starts_with($line, 'This document outlines')))) {
$inToc = false;
}
if (!$inToc) {
if (preg_match('/^(##+) (.*)/', $line, $matches)) {
$level = strlen($matches[1]) - 1; // ## is level 1 in TOC
if ($level > 0) {
$anchor = strtolower(trim($matches[2]));
$anchor = str_replace('~~', '', $anchor);
$anchor = preg_replace('/[^a-z0-9]+/', '-', $anchor);
$anchor = trim($anchor, '-');
$headers[] = [
'level' => $level,
'title' => trim($matches[2]),
'anchor' => $anchor
];
}
// Add "Back to Top" breadcrumb before level 2 headers, except for the first one or if already present
if ($level === 1 && !empty($bodyLines)) {
$lastLine = end($bodyLines);
if ($lastLine !== '' && !str_contains($lastLine, '[↑ Back to Top]')) {
$bodyLines[] = '';
$bodyLines[] = '[↑ Back to Top](#table-of-contents)';
}
}
}
$bodyLines[] = $line;
}
}
// Generate TOC text
$tocText = "## Table of Contents\n";
foreach ($headers as $header) {
if ($header['title'] === 'Table of Contents') continue;
$indent = str_repeat(' ', $header['level'] - 1);
$tocText .= "{$indent}- [{$header['title']}](#{$header['anchor']})\n";
}
// Reconstruct file
$finalLines = [];
$tocInserted = false;
foreach ($bodyLines as $line) {
if (!$tocInserted && (str_starts_with($line, '## ') || (str_contains($content, 'This document outlines') && str_starts_with($line, 'This document outlines')))) {
$finalLines[] = $tocText;
$tocInserted = true;
}
$finalLines[] = $line;
}
file_put_contents($filePath, implode("\n", $finalLines));
echo "$name TOC and breadcrumbs regenerated successfully.\n";
}
$root = __DIR__ . '/../..';
generateToc($root . '/SPECS.md', 'SPECS.md');
generateToc($root . '/MILESTONES.md', 'MILESTONES.md');

View file

@ -1,58 +0,0 @@
{
"name": "getphred/phred",
"description": "Phred Framework",
"type": "project",
"require": {
"php": "^8.2",
"crell/api-problem": "^3.7",
"filp/whoops": "^2.15",
"getphred/eyrie": "dev-main",
"getphred/flagpole": "dev-main",
"getphred/pairity": "dev-main",
"laravel/serializable-closure": "^1.3",
"lcobucci/jwt": "^5.2",
"league/flysystem": "^3.24",
"middlewares/cors": "^0.4.0",
"monolog/monolog": "^3.5",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
"php-di/php-di": "^7.0",
"relay/relay": "^2.1",
"symfony/console": "^7.0",
"vlucas/phpdotenv": "^5.6",
"zircote/swagger-php": "^4.8"
},
"require-dev": {
"codeception/codeception": "^5.1",
"codeception/module-asserts": "^3.0",
"codeception/module-phpbrowser": "^3.0",
"codeception/module-rest": "^3.3",
"fakerphp/faker": "^1.23",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5"
},
"autoload": {
"psr-4": {
"Phred\\": "src/",
"App\\": "app/",
"Modules\\": "modules/",
"Pairity\\": "vendor/getphred/pairity/src/",
"Eyrie\\": "vendor/getphred/eyrie/src/",
"Flagpole\\": "vendor/getphred/flagpole/src/",
"Codeception\\": "vendor/codeception/codeception/src/Codeception/"
}
},
"autoload-dev": {
"psr-4": {
"Phred\\Tests\\": "tests/"
}
},
"bin": [
"phred"
],
"config": {
"allow-plugins": {
"php-http/discovery": true
}
}
}

View file

@ -1,5 +0,0 @@
{
"autoload": {
"psr-4": []
}
}

View file

@ -1,8 +0,0 @@
parameters:
level: 5
paths:
- src
checkGenericClassInNonGenericObjectType: false
checkMissingIterableValueType: false
checkUninitializedProperties: true
inferPrivatePropertyTypeFromConstructor: true

3
phred
View file

@ -1,3 +0,0 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/bin/phred';

View file

@ -1,2 +0,0 @@
@ECHO OFF
php "%~dp0bin\phred" %*

View file

@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Console;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
/**
* Base command providing a Laravel-like developer experience.
* Define $command, $description, and $options; implement handle().
*/
abstract class Command
{
protected string $command = '';
protected string $description = '';
/** @var array<string,array> */
protected array $options = [];
public function getName(): string { return $this->command; }
public function setName(string $name): void { $this->command = $name; }
public function getDescription(): string { return $this->description; }
/** @return array<string,array> */
public function getOptions(): array { return $this->options; }
abstract public function handle(Input $input, Output $output): int;
public function toSymfony(): SymfonyCommand
{
$self = $this;
return new class($self->getName(), $self) extends SymfonyCommand {
public function __construct(private string $name, private Command $wrapped)
{
parent::__construct($name);
}
protected function configure(): void
{
$this->setDescription($this->wrapped->getDescription());
foreach ($this->wrapped->getOptions() as $key => $def) {
$mode = $def['mode'] ?? 'argument';
$description = $def['description'] ?? '';
$default = $def['default'] ?? null;
if ($mode === 'argument') {
$argMode = ($def['required'] ?? false)
? InputArgument::REQUIRED
: InputArgument::OPTIONAL;
$this->addArgument($key, $argMode, $description, $default);
} elseif ($mode === 'flag') {
$shortcut = $def['shortcut'] ?? null;
$this->addOption(ltrim($key, '-'), $shortcut, InputOption::VALUE_NONE, $description);
} else { // option
$shortcut = $def['shortcut'] ?? null;
$valueReq = $def['valueRequired'] ?? true;
$valueMode = $valueReq ? InputOption::VALUE_REQUIRED : InputOption::VALUE_OPTIONAL;
$this->addOption(ltrim($key, '-'), $shortcut, $valueMode, $description, $default);
}
}
}
protected function execute(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output): int
{
return $this->wrapped->handle($input, $output);
}
};
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Flags\Contracts;
interface FeatureFlagClientInterface
{
public function isEnabled(string $flagKey, array $context = []): bool;
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Flags;
use Phred\Flags\Contracts\FeatureFlagClientInterface;
final class FlagpoleClient implements FeatureFlagClientInterface
{
private \Flagpole\FeatureManager $manager;
public function __construct()
{
// For now, use an empty repository or load from config if needed.
// Milestone M10 calls for a default adapter using Flagpole.
$this->manager = new \Flagpole\FeatureManager(
new \Flagpole\Repository\InMemoryFlagRepository()
);
}
public function isEnabled(string $flagKey, array $context = []): bool
{
return $this->manager->enabled($flagKey, new \Flagpole\Context($context));
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http;
use Psr\Http\Message\ResponseInterface;
/**
* Abstraction for producing API responses.
* Implementations should honor the configured API format (REST or JSON:API).
*/
interface ApiResponseFactoryInterface
{
/**
* 200 OK with serialized payload.
* $context may contain format-specific hints (e.g., JSON:API resource type, includes, fields).
*/
public function ok(mixed $data, array $context = []): ResponseInterface;
/**
* 201 Created with Location header and serialized payload.
*/
public function created(string $location, mixed $data, array $context = []): ResponseInterface;
/**
* Generic JSON error payload (format-specific). Not a replacement for Problem Details middleware.
*/
public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface;
}

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Psr\Http\Message\ResponseInterface;
interface ApiResponseFactoryInterface
{
/**
* Generic 200 OK with array payload.
* Implementations must set appropriate Content-Type.
* @param array<string,mixed> $data
*/
public function ok(array $data = []): ResponseInterface;
/**
* 201 Created with array payload.
* @param array<string,mixed> $data
* @param string|null $location Optional Location header
*/
public function created(array $data = [], ?string $location = null): ResponseInterface;
/**
* 204 No Content
*/
public function noContent(): ResponseInterface;
/**
* Error response with status and details.
* @param int $status HTTP status code (4xx/5xx)
* @param string $title Short, human-readable summary
* @param string|null $detail Detailed description
* @param array<string,mixed> $extra Extra members dependent on format
*/
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface;
/**
* Create a response from a raw associative array payload.
* @param array<string,mixed> $payload
* @param int $status
*/
public function fromArray(array $payload, int $status = 200): ResponseInterface;
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Psr\Http\Message\ServerRequestInterface;
interface ErrorFormatNegotiatorInterface
{
/**
* Determine desired API format based on the request (e.g., Accept header).
* Should return 'rest', 'jsonapi', or 'xml'.
*/
public function apiFormat(ServerRequestInterface $request): string;
/**
* Determine if the client prefers an HTML error representation.
*/
public function wantsHtml(ServerRequestInterface $request): bool;
}

View file

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Throwable;
interface ExceptionToStatusMapperInterface
{
/**
* Map a Throwable to an HTTP status code (400599), defaulting to 500 when out of range.
*/
public function map(Throwable $e): int;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Contracts;
use Psr\Http\Message\ServerRequestInterface;
interface RequestIdProviderInterface
{
/**
* Returns a correlation/request ID for the given request.
* Implementations may reuse an incoming header or generate a new one.
*/
public function provide(ServerRequestInterface $request): string;
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Controllers;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
use Psr\Http\Message\ServerRequestInterface as Request;
final class FormatController
{
public function __construct(private ApiResponseFactoryInterface $responses) {}
public function __invoke(Request $request)
{
$format = $request->getAttribute(Negotiation::ATTR_API_FORMAT, 'rest');
return $this->responses->ok(['format' => $format]);
}
}

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Controllers;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
final class HealthController
{
public function __construct(private ApiResponseFactoryInterface $factory) {}
public function __invoke(Request $request): ResponseInterface
{
return $this->factory->ok([
'ok' => true,
'framework' => 'Phred',
]);
}
}

View file

@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\JsonApi;
use LogicException;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
use Phred\Http\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Minimal JSON:API response factory stub.
* For full functionality, require "neomerx/json-api" and replace internals accordingly.
*/
class JsonApiResponseFactory implements ApiResponseFactoryInterface
{
public function ok(mixed $data, array $context = []): ResponseInterface
{
$document = $this->toResourceDocument($data, $context);
return $this->jsonApi(200, $document);
}
public function created(string $location, mixed $data, array $context = []): ResponseInterface
{
$document = $this->toResourceDocument($data, $context);
$response = $this->jsonApi(201, $document);
return $response->withHeader('Location', $location);
}
public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface
{
$payload = [
'errors' => [[
'status' => (string) $status,
'title' => $title,
'detail' => $detail,
'meta' => (object) $meta,
]],
];
return $this->jsonApi($status, $payload);
}
private function jsonApi(int $status, array $document): ResponseInterface
{
// If neomerx/json-api is installed, you can swap this simple encoding with its encoder.
$json = json_encode($document, JSON_THROW_ON_ERROR);
$stream = Stream::create($json);
return (new Response($status, ['Content-Type' => 'application/vnd.api+json']))->withBody($stream);
}
/**
* Convert domain data to a very simple JSON:API resource document.
* Context may include: 'type' (required for non-array scalars), 'id', 'includes', 'links', 'meta'.
* This is intentionally minimal until a full encoder is wired.
*
* @param mixed $data
* @param array $context
* @return array
*/
private function toResourceDocument(mixed $data, array $context): array
{
// If neomerx/json-api not present, produce a simple document requiring caller to provide 'type'.
if (!isset($context['type'])) {
// Keep developer feedback explicit to encourage proper setup.
throw new LogicException('JSON:API response requires context["type"]. Consider installing neomerx/json-api for advanced encoding.');
}
$resource = [
'type' => (string) $context['type'],
];
if (is_array($data) && array_key_exists('id', $data)) {
$resource['id'] = (string) $data['id'];
$attributes = $data;
unset($attributes['id']);
} else {
$attributes = $data;
if (isset($context['id'])) {
$resource['id'] = (string) $context['id'];
}
}
$resource['attributes'] = $attributes;
$document = ['data' => $resource];
if (!empty($context['links']) && is_array($context['links'])) {
$document['links'] = $context['links'];
}
if (!empty($context['meta']) && is_array($context['meta'])) {
$document['meta'] = $context['meta'];
}
return $document;
}
}

View file

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http;
use DI\Container;
use DI\ContainerBuilder;
use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
use Relay\Relay;
use function FastRoute\simpleDispatcher;
/**
* Core HTTP Kernel builds container, routes, and PSR-15 pipeline and processes requests.
*/
final class Kernel
{
private Container $container;
private Dispatcher $dispatcher;
public function __construct(?Container $container = null, ?Dispatcher $dispatcher = null)
{
$this->container = $container ?? $this->buildContainer();
// Providers may contribute routes during boot; ensure dispatcher is built after container init
$this->dispatcher = $dispatcher ?? $this->buildDispatcher();
}
public function container(): Container
{
return $this->container;
}
public function dispatcher(): Dispatcher
{
return $this->dispatcher;
}
public function handle(ServerRequest $request): ResponseInterface
{
$psr17 = new Psr17Factory();
$config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class);
// CORS
$corsSettings = new \Neomerx\Cors\Strategies\Settings();
$corsSettings->init(
parse_url((string)getenv('APP_URL'), PHP_URL_SCHEME) ?: 'http',
parse_url((string)getenv('APP_URL'), PHP_URL_HOST) ?: 'localhost',
(int)parse_url((string)getenv('APP_URL'), PHP_URL_PORT) ?: 80
);
$corsSettings->setAllowedOrigins($config->get('cors.origin', ['*']));
$corsSettings->setAllowedMethods($config->get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']));
$corsSettings->setAllowedHeaders($config->get('cors.headers.allow', ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With']));
$corsSettings->enableAllOriginsAllowed();
$corsSettings->enableAllMethodsAllowed();
$corsSettings->enableAllHeadersAllowed();
$middleware = [];
if (filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN)) {
$middleware[] = new class extends Middleware\Middleware {
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
{
self::$timings = []; // Reset timings for each request in debug mode
$response = $handler->handle($request);
$timings = self::getTimings();
if (!empty($timings)) {
$encoded = json_encode($timings, JSON_UNESCAPED_SLASHES);
if ($encoded) {
$response = $response->withHeader('X-Phred-Timings', $encoded);
}
}
return $response;
}
};
}
$middleware = array_merge($middleware, [
// Security headers
new Middleware\Security\SecureHeadersMiddleware($config),
// CORS
new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)),
new Middleware\ProblemDetailsMiddleware(
filter_var($config->get('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOLEAN),
null,
null,
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
),
// Perform extension-based content negotiation hinting before standard negotiation
new Middleware\UrlExtensionNegotiationMiddleware(),
new Middleware\ContentNegotiationMiddleware(),
new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
new Middleware\DispatchMiddleware($psr17),
]);
$relay = new Relay($middleware);
return $relay->handle($request);
}
private function buildContainer(): Container
{
$builder = new ContainerBuilder();
// Allow service providers to register definitions before defaults
$configAdapter = new \Phred\Support\DefaultConfig();
$providers = new \Phred\Support\ProviderRepository($configAdapter);
$providers->load();
$providers->registerAll($builder);
// Add core definitions/bindings
$builder->addDefinitions([
\Phred\Support\Contracts\ConfigInterface::class => \DI\autowire(\Phred\Support\DefaultConfig::class),
\Phred\Http\Contracts\ErrorFormatNegotiatorInterface::class => \DI\autowire(\Phred\Http\Support\DefaultErrorFormatNegotiator::class),
\Phred\Http\Contracts\RequestIdProviderInterface::class => \DI\autowire(\Phred\Http\Support\DefaultRequestIdProvider::class),
\Phred\Http\Contracts\ExceptionToStatusMapperInterface::class => \DI\autowire(\Phred\Http\Support\DefaultExceptionToStatusMapper::class),
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => \DI\autowire(\Phred\Http\Responses\DelegatingApiResponseFactory::class),
\Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class),
\Phred\Http\Responses\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
\Phred\Http\Responses\XmlResponseFactory::class => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class),
]);
$container = $builder->build();
// Reset provider-registered routes to avoid duplicates across multiple kernel instantiations (e.g., tests)
\Phred\Http\Routing\RouteRegistry::clear();
// Boot providers after container is available
$providers->bootAll($container);
return $container;
}
private function buildDispatcher(): Dispatcher
{
$routesPath = dirname(__DIR__, 2) . '/routes';
$collector = static function (RouteCollector $r) use ($routesPath): void {
// Load user-defined routes if present
$router = new Router($r);
foreach (['web.php', 'api.php'] as $file) {
$path = $routesPath . '/' . $file;
if (is_file($path)) {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($path) { require $path; })($router);
}
}
// Load module route files under prefixes defined in routes/web.php via RouteGroups includes.
// Additionally, as a convenience, auto-mount modules without explicit includes using folder name as prefix.
$modulesDir = dirname(__DIR__, 2) . '/modules';
if (is_dir($modulesDir)) {
$entries = array_values(array_filter(scandir($modulesDir) ?: [], static fn($e) => $e !== '.' && $e !== '..'));
sort($entries, SORT_STRING);
foreach ($entries as $mod) {
$modRoutes = $modulesDir . '/' . $mod . '/Routes';
if (!is_dir($modRoutes)) {
continue;
}
// Auto-mount only if the module's web.php wasn't already included via RouteGroups in root file.
$autoInclude = function (string $relative, string $prefix) use ($modRoutes, $router): void {
$file = $modRoutes . '/' . $relative;
if (is_file($file)) {
$router->group('/' . strtolower($prefix), static function (Router $r) use ($file): void {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($file) { require $file; })($r);
});
}
};
$autoInclude('web.php', $mod);
// api.php can be auto-mounted under /api/<module>
$apiFile = $modRoutes . '/api.php';
if (is_file($apiFile)) {
$router->group('/api/' . strtolower($mod), static function (Router $r) use ($apiFile): void {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($apiFile) { require $apiFile; })($r);
});
}
}
}
// Allow providers to contribute routes
\Phred\Http\Routing\RouteRegistry::apply($r, $router);
// Ensure default demo routes exist for acceptance/demo
$r->addRoute('GET', '/_phred/health', [Controllers\HealthController::class, '__invoke']);
$r->addRoute('GET', '/_phred/format', [Controllers\FormatController::class, '__invoke']);
};
return simpleDispatcher($collector);
}
}

View file

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
use Phred\Http\Support\DefaultErrorFormatNegotiator;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\DefaultConfig;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class ContentNegotiationMiddleware extends Middleware
{
public function __construct(
private readonly ?ConfigInterface $config = null,
private readonly ?ErrorFormatNegotiatorInterface $negotiator = null,
) {}
public const ATTR_API_FORMAT = 'phred.api_format'; // 'rest' | 'jsonapi' | 'xml'
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$format = $this->profileSelf(function () use ($request) {
$cfg = $this->config ?? new DefaultConfig();
$defaultFormat = strtolower((string) $cfg->get('api_format', 'rest'));
// Allow Accept header to override
$accept = $request->getHeaderLine('Accept');
if (str_contains($accept, 'application/vnd.api+json')) {
return 'jsonapi';
}
if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) {
return 'xml';
}
if (str_contains($accept, 'application/json') || str_contains($accept, 'application/problem+json')) {
return 'rest';
}
return $defaultFormat;
});
return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format));
}
}

View file

@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use DI\ContainerBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\RequestContext;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
final class DispatchMiddleware implements MiddlewareInterface
{
public function __construct(
private Psr17Factory $psr17
) {}
public function process(ServerRequest $request, Handler $handler): ResponseInterface
{
$handlerSpec = $request->getAttribute('phred.route.handler');
$vars = (array) $request->getAttribute('phred.route.vars', []);
if (!$handlerSpec) {
return $this->jsonError('No route handler', 500);
}
$requestContainer = $this->buildRequestScopedContainer($request);
$callable = $this->resolveCallable($handlerSpec, $requestContainer);
RequestContext::set($request);
try {
$response = $this->invokeCallable($callable, $request, $vars);
} finally {
RequestContext::clear();
}
if (!$response instanceof ResponseInterface) {
return $this->normalizeToResponse($response);
}
return $response;
}
/**
* @param mixed $payload
*/
private function normalizeToResponse(mixed $payload): ResponseInterface
{
if (is_array($payload)) {
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
$res->getBody()->write((string) $json);
return $res;
}
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'text/plain; charset=utf-8');
$res->getBody()->write((string) $payload);
return $res;
}
private function jsonError(string $message, int $status): ResponseInterface
{
$res = $this->psr17->createResponse($status)->withHeader('Content-Type', 'application/json');
$res->getBody()->write(json_encode(['error' => $message], JSON_UNESCAPED_SLASHES));
return $res;
}
private function buildRequestScopedContainer(ServerRequest $request): \DI\Container
{
$format = (string) ($request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest'));
$builder = new ContainerBuilder();
$definition = match ($format) {
'jsonapi' => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
'xml' => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class),
default => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class),
};
$builder->addDefinitions([
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => $definition,
]);
return $builder->build();
}
private function resolveCallable(mixed $handlerSpec, \DI\Container $requestContainer): callable
{
if (is_array($handlerSpec) && isset($handlerSpec[0]) && is_string($handlerSpec[0])) {
$class = $handlerSpec[0];
$method = $handlerSpec[1] ?? '__invoke';
$controller = $requestContainer->get($class);
return [$controller, $method];
}
if (is_string($handlerSpec) && class_exists($handlerSpec)) {
$controller = $requestContainer->get($handlerSpec);
return [$controller, '__invoke'];
}
return $handlerSpec; // already a callable/closure
}
private function invokeCallable(callable $callable, ServerRequest $request, array $vars): mixed
{
return $callable($request, ...array_values($vars));
}
}

View file

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
abstract class Middleware implements MiddlewareInterface
{
/** @var array<string, float> */
protected static array $timings = [];
abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface;
/**
* Wrap a handler and measure its execution time.
*/
protected function profile(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$start = microtime(true);
try {
return $handler->handle($request);
} finally {
$duration = microtime(true) - $start;
self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration;
}
}
/**
* Simple profiler for middleware that don't need to wrap the handler.
*/
protected function profileSelf(callable $callback): mixed
{
$start = microtime(true);
try {
return $callback();
} finally {
$duration = microtime(true) - $start;
self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration;
}
}
/**
* Get all recorded timings.
* @return array<string, float>
*/
public static function getTimings(): array
{
return self::$timings;
}
/**
* Record a timing manually.
*/
public static function recordTiming(string $key, float $duration): void
{
self::$timings[$key] = (self::$timings[$key] ?? 0) + $duration;
}
protected function json(array $data, int $status = 200): ResponseInterface
{
$response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']);
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES));
return $response;
}
}

View file

@ -1,198 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Crell\ApiProblem\ApiProblem;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
use Phred\Http\Contracts\ExceptionToStatusMapperInterface;
use Phred\Http\Contracts\RequestIdProviderInterface;
use Phred\Http\Support\DefaultExceptionToStatusMapper;
use Phred\Http\Support\DefaultErrorFormatNegotiator;
use Phred\Http\Support\DefaultRequestIdProvider;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\DefaultConfig;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
class ProblemDetailsMiddleware extends Middleware
{
public function __construct(
private readonly bool $debug = false,
private readonly ?RequestIdProviderInterface $requestIdProvider = null,
private readonly ?ExceptionToStatusMapperInterface $statusMapper = null,
private readonly ?bool $useProblemDetails = null,
private readonly ?ErrorFormatNegotiatorInterface $negotiator = null,
private readonly ?ConfigInterface $config = null,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
return $this->profile($request, $handler);
} catch (Throwable $e) {
$useProblem = $this->shouldUseProblemDetails();
$format = $this->determineApiFormat($request);
$requestId = $this->provideRequestId($request);
if ($this->shouldRenderHtml($request)) {
return $this->renderWhoopsHtml($e, $requestId);
}
$detail = $this->computeDetail($e);
$status = $this->mapStatus($e);
if ($useProblem && $format !== 'jsonapi') {
return $this->respondProblemDetails($e, $status, $detail, $requestId);
}
return $this->respondJsonApiOrJson($e, $status, $detail, $format, $requestId);
}
}
private function deriveStatus(Throwable $e): int
{
// Kept for backward compatibility in case of external references; delegate to default mapper.
return (new DefaultExceptionToStatusMapper())->map($e);
}
private function shortClass(object $o): string
{
$fqcn = get_class($o);
$pos = strrpos($fqcn, chr(92)); // '\\' as ASCII 92
if ($pos !== false) {
return substr($fqcn, $pos + 1);
}
return $fqcn;
}
private function shouldUseProblemDetails(): bool
{
$cfg = $this->config ?? new DefaultConfig();
$raw = $this->useProblemDetails ?? $cfg->get('API_PROBLEM_DETAILS', 'true');
return filter_var((string) $raw, FILTER_VALIDATE_BOOLEAN);
}
private function determineApiFormat(ServerRequestInterface $request): string
{
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
return $neg->apiFormat($request);
}
private function provideRequestId(ServerRequestInterface $request): string
{
$provider = $this->requestIdProvider ?? new DefaultRequestIdProvider();
return $provider->provide($request);
}
private function shouldRenderHtml(ServerRequestInterface $request): bool
{
if (!$this->debug) {
return false;
}
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
return $neg->wantsHtml($request);
}
private function computeDetail(Throwable $e): string
{
if ($this->debug) {
return $e->getMessage() . "\n\n" . $e->getTraceAsString();
}
return $e->getMessage();
}
private function mapStatus(Throwable $e): int
{
$mapper = $this->statusMapper ?? new DefaultExceptionToStatusMapper();
return $mapper->map($e);
}
private function renderWhoopsHtml(Throwable $e, string $requestId): ResponseInterface
{
if (class_exists(\Whoops\Run::class)) {
$handler = new \Whoops\Handler\PrettyPageHandler();
$whoops = new \Whoops\Run();
$whoops->allowQuit(false);
$whoops->writeToOutput(false);
$whoops->pushHandler($handler);
$html = (string) $whoops->handleException($e);
if ($html === '') {
ob_start();
$handler->handle($e);
$html = (string) ob_get_clean();
}
if ($html === '') {
$html = '<!doctype html><html><head><meta charset="utf-8"><title>Whoops</title></head><body><h1>Whoops</h1><pre>'
. htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</pre></body></html>';
}
$stream = Stream::create($html);
return (new Response(500, [
'Content-Type' => 'text/html; charset=UTF-8',
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
return $this->respondPlainTextFallback($e->getMessage(), $requestId);
}
private function respondPlainTextFallback(string $message, string $requestId): ResponseInterface
{
$stream = Stream::create($message);
return (new Response(500, [
'Content-Type' => 'text/plain; charset=UTF-8',
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
private function respondProblemDetails(Throwable $e, int $status, string $detail, string $requestId): ResponseInterface
{
$problem = new ApiProblem($this->shortClass($e) ?: 'Error');
$problem->setType('about:blank');
$problem->setTitle($this->shortClass($e));
$problem->setStatus($status);
$problem->setDetail($detail ?: 'An error occurred');
if ($this->debug) {
$problem['exception'] = [
'class' => get_class($e),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
$json = json_encode($problem, JSON_THROW_ON_ERROR);
$stream = Stream::create($json);
return (new Response($status, [
'Content-Type' => 'application/problem+json',
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
private function respondJsonApiOrJson(Throwable $e, int $status, string $detail, string $format, string $requestId): ResponseInterface
{
$payload = [
'errors' => [[
'status' => (string) $status,
'title' => $this->shortClass($e),
'detail' => $detail,
]],
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$stream = Stream::create($json);
$contentType = $format === 'jsonapi' ? 'application/vnd.api+json' : 'application/json';
return (new Response($status, [
'Content-Type' => $contentType,
'X-Request-Id' => $requestId,
]))->withBody($stream);
}
}

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use FastRoute\Dispatcher;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
final class RoutingMiddleware implements MiddlewareInterface
{
public function __construct(
private Dispatcher $dispatcher,
private Psr17Factory $psr17
) {}
public function process(ServerRequest $request, Handler $handler): ResponseInterface
{
$routeInfo = $this->dispatcher->dispatch($request->getMethod(), $request->getUri()->getPath());
switch ($routeInfo[0]) {
case Dispatcher::NOT_FOUND:
$response = $this->psr17->createResponse(404);
$response->getBody()->write(json_encode(['error' => 'Not Found'], JSON_UNESCAPED_SLASHES));
return $response->withHeader('Content-Type', 'application/json');
case Dispatcher::METHOD_NOT_ALLOWED:
$response = $this->psr17->createResponse(405);
$response->getBody()->write(json_encode(['error' => 'Method Not Allowed'], JSON_UNESCAPED_SLASHES));
return $response->withHeader('Content-Type', 'application/json');
case Dispatcher::FOUND:
[$status, $handlerSpec, $vars] = $routeInfo;
$request = $request
->withAttribute('phred.route.handler', $handlerSpec)
->withAttribute('phred.route.vars', $vars);
return $handler->handle($request);
default:
$response = $this->psr17->createResponse(500);
$response->getBody()->write(json_encode(['error' => 'Routing failure'], JSON_UNESCAPED_SLASHES));
return $response->withHeader('Content-Type', 'application/json');
}
}
}

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware\Security;
use Phred\Http\Middleware\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Basic CSRF protection middleware.
* Expects a token in '_csrf' parameter for state-changing requests or 'X-CSRF-TOKEN' header.
*/
final class CsrfMiddleware extends Middleware
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$method = $request->getMethod();
if (in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true)) {
return $handler->handle($request);
}
$session = $request->getAttribute('session');
$token = null;
if ($session && method_exists($session, 'get')) {
$token = $session->get('_csrf_token');
}
$provided = $request->getParsedBody()['_csrf'] ?? $request->getHeaderLine('X-CSRF-TOKEN');
if (!$token || $token !== $provided) {
// In a real app, we might throw a specific exception that maps to 419 or 403
throw new \RuntimeException('CSRF token mismatch', 403);
}
return $handler->handle($request);
}
}

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware\Security;
use Phred\Http\Middleware\Middleware;
use Phred\Support\Contracts\ConfigInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Middleware to add common security headers to the response.
*/
final class SecureHeadersMiddleware extends Middleware
{
public function __construct(
private readonly ConfigInterface $config
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $this->profile($request, $handler);
// Standard security headers
$response = $response->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('X-Frame-Options', 'SAMEORIGIN')
->withHeader('X-XSS-Protection', '1; mode=block')
->withHeader('Referrer-Policy', 'no-referrer-when-downgrade')
->withHeader('Content-Security-Policy', $this->config->get('security.csp', "default-src 'self'"));
if ($this->config->get('security.hsts', true)) {
$response = $response->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}

View file

@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Phred\Support\Config;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
/**
* Parses a trailing URL extension and hints content negotiation.
*
* - Controlled by env/config `URL_EXTENSION_NEGOTIATION` (bool, default true)
* - Allowed extensions by env/config `URL_EXTENSION_WHITELIST`
* - Pipe-separated list: e.g., "json|php|none" (default: "json|php|none")
* - Behavior:
* - Detects and strips ".ext" at the end of path if ext is whitelisted (except `none` which means no ext)
* - Sets request attribute `phred.format_hint` to ext (json|xml|html) mapping:
* json -> json
* xml -> xml (not implemented yet; reserved for M12)
* php/none -> html
* - Optionally, sets Accept header mapping for downstream negotiation:
* json -> application/json
* xml -> application/xml (reserved)
* html -> text/html
*/
final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface
{
public const ATTR_FORMAT_HINT = 'phred.format_hint';
public function process(Request $request, Handler $handler): ResponseInterface
{
$enabled = filter_var((string) Config::get('URL_EXTENSION_NEGOTIATION', 'true'), FILTER_VALIDATE_BOOLEAN);
if (!$enabled) {
return $handler->handle($request);
}
$whitelistRaw = (string) Config::get('URL_EXTENSION_WHITELIST', 'json|xml|php|none');
$allowed = array_filter(array_map('trim', explode('|', strtolower($whitelistRaw))));
$allowed = $allowed ?: ['json', 'xml', 'php', 'none'];
$uri = $request->getUri();
$path = $uri->getPath();
$ext = null;
if (preg_match('/\.([a-z0-9]+)$/i', $path, $m)) {
$candidate = strtolower($m[1]);
if (in_array($candidate, $allowed, true)) {
$ext = $candidate;
// strip the extension from the path for routing purposes
$path = substr($path, 0, - (strlen($candidate) + 1));
}
} else {
// no extension → treat as 'none' if allowed
if (in_array('none', $allowed, true)) {
$ext = 'none';
}
}
if ($ext !== null) {
$hint = $this->mapToHint($ext);
if ($hint !== null) {
$request = $request->withAttribute(self::ATTR_FORMAT_HINT, $hint);
// Only set Accept for explicit JSON (and future XML), and only if client didn't set one.
$accept = $this->mapToAccept($hint);
if ($accept !== null) {
$current = trim($request->getHeaderLine('Accept'));
if ($current === '') {
$request = $request->withHeader('Accept', $accept);
}
}
}
}
// If we modified the path, update the URI so router matches sans extension
if ($path !== $uri->getPath()) {
$newUri = $uri->withPath($path === '' ? '/' : $path);
$request = $request->withUri($newUri);
}
return $handler->handle($request);
}
private function mapToHint(string $ext): ?string
{
return match ($ext) {
'json' => 'json',
'xml' => 'xml', // reserved for M12
'php', 'none' => 'html',
default => null,
};
}
private function mapToAccept(string $hint): ?string
{
return match ($hint) {
'json' => 'application/json',
'xml' => 'application/xml', // reserved for M12
default => null, // do not force Accept for html/none
};
}
}

View file

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
abstract class ValidationMiddleware extends Middleware
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$errors = $this->validate($request);
if (!empty($errors)) {
return $this->json([
'errors' => $errors
], 422);
}
return $handler->handle($request);
}
/**
* @return array<string, mixed> List of validation errors
*/
abstract protected function validate(ServerRequestInterface $request): array;
}

View file

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http;
use Psr\Http\Message\ServerRequestInterface;
/**
* Minimal request context holder for the current request during dispatch.
* DispatchMiddleware sets/clears it around controller invocation so that
* other services (e.g., response factory selector) can inspect negotiation.
*/
final class RequestContext
{
private static ?ServerRequestInterface $current = null;
public static function set(ServerRequestInterface $request): void
{
self::$current = $request;
}
public static function get(): ?ServerRequestInterface
{
return self::$current;
}
public static function clear(): void
{
self::$current = null;
}
}

View file

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
use Phred\Http\RequestContext;
use Psr\Http\Message\ResponseInterface;
/**
* Delegates to REST or JSON:API factory depending on current request format.
* Controllers receive this via DI and call its methods; it inspects
* RequestContext (set in DispatchMiddleware) to choose the underlying factory.
*/
final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(
private RestResponseFactory $rest,
private JsonApiResponseFactory $jsonapi,
private XmlResponseFactory $xml
) {}
public function ok(array $data = []): ResponseInterface
{
return $this->delegate()->ok($data);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
return $this->delegate()->created($data, $location);
}
public function noContent(): ResponseInterface
{
return $this->delegate()->noContent();
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
return $this->delegate()->error($status, $title, $detail, $extra);
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
return $this->delegate()->fromArray($payload, $status);
}
private function delegate(): ApiResponseFactoryInterface
{
$req = RequestContext::get();
$format = $req?->getAttribute(Negotiation::ATTR_API_FORMAT) ?? 'rest';
return match ($format) {
'jsonapi' => $this->jsonapi,
'xml' => $this->xml,
default => $this->rest,
};
}
}

View file

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
final class JsonApiResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(private Psr17Factory $psr17 = new Psr17Factory()) {}
public function ok(array $data = []): ResponseInterface
{
return $this->document(['data' => $data], 200);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
$res = $this->document(['data' => $data], 201);
if ($location) {
$res = $res->withHeader('Location', $location);
}
return $res;
}
public function noContent(): ResponseInterface
{
// JSON:API allows 204 without body
return $this->psr17->createResponse(204);
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
$error = array_filter([
'status' => (string) $status,
'title' => $title,
'detail' => $detail,
], static fn($v) => $v !== null && $v !== '');
if (!empty($extra)) {
$error = array_merge($error, $extra);
}
return $this->document(['errors' => [$error]], $status);
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
// Caller must ensure payload is a valid JSON:API document shape
return $this->document($payload, $status);
}
/**
* @param array<string,mixed> $doc
*/
private function document(array $doc, int $status): ResponseInterface
{
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/vnd.api+json');
$res->getBody()->write(json_encode($doc, JSON_UNESCAPED_SLASHES));
return $res;
}
}

View file

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
final class RestResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(private Psr17Factory $psr17 = new Psr17Factory()) {}
public function ok(array $data = []): ResponseInterface
{
return $this->json($data, 200);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
$res = $this->json($data, 201);
if ($location) {
$res = $res->withHeader('Location', $location);
}
return $res;
}
public function noContent(): ResponseInterface
{
return $this->psr17->createResponse(204);
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
$payload = array_merge([
'type' => $extra['type'] ?? 'about:blank',
'title' => $title,
'status' => $status,
], $detail !== null ? ['detail' => $detail] : [], $extra);
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/problem+json');
$res->getBody()->write(json_encode($payload, JSON_UNESCAPED_SLASHES));
return $res;
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
return $this->json($payload, $status);
}
private function json(array $data, int $status): ResponseInterface
{
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/json');
$res->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES));
return $res;
}
}

View file

@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Serializer;
final class XmlResponseFactory implements ApiResponseFactoryInterface
{
private Serializer $serializer;
public function __construct(private Psr17Factory $psr17 = new Psr17Factory())
{
$this->serializer = new Serializer([new ArrayDenormalizer()], [new XmlEncoder()]);
}
public function ok(array $data = []): ResponseInterface
{
return $this->xml($data, 200);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
$res = $this->xml($data, 201);
if ($location) {
$res = $res->withHeader('Location', $location);
}
return $res;
}
public function noContent(): ResponseInterface
{
return $this->psr17->createResponse(204);
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
$payload = array_merge([
'title' => $title,
'status' => $status,
], $detail !== null ? ['detail' => $detail] : [], $extra);
return $this->xml(['error' => $payload], $status);
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
return $this->xml($payload, $status);
}
private function xml(array $data, int $status): ResponseInterface
{
$xml = $this->serializer->serialize($data, 'xml');
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/xml');
$res->getBody()->write($xml);
return $res;
}
}

View file

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Rest;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
use Phred\Http\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\SerializerInterface;
class RestResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(private readonly SerializerInterface $serializer)
{
}
public function ok(mixed $data, array $context = []): ResponseInterface
{
return $this->json(200, $data, $context);
}
public function created(string $location, mixed $data, array $context = []): ResponseInterface
{
$response = $this->json(201, $data, $context);
return $response->withHeader('Location', $location);
}
public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface
{
$payload = [
'title' => $title,
'detail' => $detail,
'meta' => (object) $meta,
];
return $this->json($status, $payload);
}
private function json(int $status, mixed $data, array $context = []): ResponseInterface
{
$json = $this->serializer->serialize($data, 'json', $context);
$stream = Stream::create($json);
return (new Response($status, ['Content-Type' => 'application/json']))->withBody($stream);
}
}

View file

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http;
use FastRoute\RouteCollector;
/**
* Tiny facade around FastRoute\RouteCollector to offer a friendly API in route files.
*/
final class Router
{
public function __construct(private RouteCollector $collector) {}
public function get(string $path, array|string|callable $handler): void
{
$this->collector->addRoute('GET', $path, $handler);
}
public function post(string $path, array|string|callable $handler): void
{
$this->collector->addRoute('POST', $path, $handler);
}
public function put(string $path, array|string|callable $handler): void
{
$this->collector->addRoute('PUT', $path, $handler);
}
public function patch(string $path, array|string|callable $handler): void
{
$this->collector->addRoute('PATCH', $path, $handler);
}
public function delete(string $path, array|string|callable $handler): void
{
$this->collector->addRoute('DELETE', $path, $handler);
}
/**
* Group routes under a common path prefix.
*
* Example:
* $router->group('/api', function (Router $r) { $r->get('/health', Handler::class); });
*/
public function group(string $prefix, callable $routes): void
{
$this->collector->addGroup($prefix, function (RouteCollector $rc) use ($routes): void {
$routes(new Router($rc));
});
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Routing;
use Phred\Http\Router;
final class RouteGroups
{
/**
* Include a set of routes under a prefix using the provided Router instance.
*/
public static function include(Router $router, string $prefix, callable $loader): void
{
$router->group($prefix, static function (Router $r) use ($loader): void {
$loader($r);
});
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Routing;
use FastRoute\RouteCollector;
use Phred\Http\Router;
/**
* Allows providers to register route callbacks that will be applied
* when the FastRoute dispatcher is built.
*/
final class RouteRegistry
{
/** @var list<callable(RouteCollector, Router):void> */
private static array $callbacks = [];
public static function add(callable $registrar): void
{
self::$callbacks[] = $registrar;
}
public static function clear(): void
{
self::$callbacks = [];
}
public static function apply(RouteCollector $collector, Router $router): void
{
foreach (self::$callbacks as $cb) {
$cb($collector, $router);
}
}
}

View file

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterface
{
public function apiFormat(ServerRequest $request): string
{
$accept = $request->getHeaderLine('Accept');
if (str_contains($accept, 'application/vnd.api+json')) {
return 'jsonapi';
}
if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) {
return 'xml';
}
return 'rest';
}
public function wantsHtml(ServerRequest $request): bool
{
$accept = $request->getHeaderLine('Accept');
// Only return true if text/html is explicitly mentioned and is likely the preferred format
return str_contains($accept, 'text/html');
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
use Phred\Http\Contracts\ExceptionToStatusMapperInterface;
use Throwable;
final class DefaultExceptionToStatusMapper implements ExceptionToStatusMapperInterface
{
public function map(Throwable $e): int
{
$code = (int) ($e->getCode() ?: 500);
if ($code < 400 || $code > 599) {
return 500;
}
return $code;
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
use Nyholm\Psr7\Response;
use Phred\Http\Contracts\RequestIdProviderInterface;
use Psr\Http\Message\ServerRequestInterface;
final class DefaultRequestIdProvider implements RequestIdProviderInterface
{
public function provide(ServerRequestInterface $request): string
{
$incoming = $request->getHeaderLine('X-Request-Id');
if ($incoming !== '') {
return $incoming;
}
return bin2hex(random_bytes(8));
}
}

View file

@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
/**
* Helper to build pagination links and metadata.
*/
final class Paginator
{
/**
* @param array<mixed> $items
*/
public function __construct(
private array $items,
private int $total,
private int $perPage,
private int $currentPage,
private string $baseUrl
) {}
public function toArray(): array
{
$lastPage = (int) ceil($this->total / $this->perPage);
return [
'data' => $this->items,
'meta' => [
'total' => $this->total,
'per_page' => $this->perPage,
'current_page' => $this->currentPage,
'last_page' => $lastPage,
'from' => ($this->currentPage - 1) * $this->perPage + 1,
'to' => min($this->currentPage * $this->perPage, $this->total),
],
'links' => [
'first' => $this->getUrl(1),
'last' => $this->getUrl($lastPage),
'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null,
'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null,
'self' => $this->getUrl($this->currentPage),
]
];
}
public function toJsonApi(): array
{
$lastPage = (int) ceil($this->total / $this->perPage);
return [
'data' => $this->items,
'meta' => [
'total' => $this->total,
'page' => [
'size' => $this->perPage,
'total' => $lastPage,
]
],
'links' => [
'first' => $this->getUrl(1),
'last' => $this->getUrl($lastPage),
'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null,
'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null,
'self' => $this->getUrl($this->currentPage),
]
];
}
private function getUrl(int $page): string
{
$url = parse_url($this->baseUrl);
$query = [];
if (isset($url['query'])) {
parse_str($url['query'], $query);
}
$query['page'] = $page;
$query['per_page'] = $this->perPage;
$queryString = http_build_query($query);
return ($url['path'] ?? '/') . '?' . $queryString;
}
}

View file

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
use Phred\Http\Contracts\ApiResponseFactoryInterface as Responses;
abstract class APIController extends Controller
{
public function __construct(protected Responses $responses) {}
protected function ok(array $data = []): object
{
return $this->responses->ok($data);
}
protected function created(array $data = [], ?string $location = null): object
{
return $this->responses->created($data, $location);
}
protected function noContent(): object
{
return $this->responses->noContent();
}
protected function error(int $status, string $title, ?string $detail = null, array $extra = []): object
{
return $this->responses->error($status, $title, $detail, $extra);
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
abstract class Controller
{
// Common utilities for future use can live here.
}

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
use Phred\Template\Contracts\RendererInterface;
abstract class View implements ViewWithDefaultTemplate
{
protected string $template = '';
public function __construct(protected RendererInterface $renderer) {}
/**
* Prepare data for the template. Subclasses may override to massage input.
*/
protected function transformData(array $data): array
{
return $data;
}
/**
* Render using transformed data and either the provided template override or the default template.
*/
public function render(array $data = [], ?string $template = null): string
{
$prepared = $this->transformData($data);
$tpl = $template ?? $this->defaultTemplate();
return $this->renderer->render($tpl, $prepared);
}
public function defaultTemplate(): string
{
return $this->template;
}
}

View file

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
abstract class ViewController extends Controller
{
private Psr17Factory $psr17;
public function __construct()
{
$this->psr17 = new Psr17Factory();
}
/**
* Return an HTML response with the provided content.
*/
protected function html(string $content, int $status = 200, array $headers = []): ResponseInterface
{
$response = $this->psr17->createResponse($status)->withHeader('Content-Type', 'text/html; charset=utf-8');
foreach ($headers as $k => $v) {
$response = $response->withHeader((string) $k, (string) $v);
}
$response->getBody()->write($content);
return $response;
}
/**
* Convenience to render a module View and return an HTML response.
* The `$template` is optional; when omitted (null), the view should use its default template.
*/
protected function renderView(View $view, array $data = [], ?string $template = null, int $status = 200, array $headers = []): ResponseInterface
{
// Delegate template selection to the View; when $template is null,
// the View may use its default template.
$markup = $view->render($data, $template);
return $this->html($markup, $status, $headers);
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
interface ViewWithDefaultTemplate
{
public function defaultTemplate(): string;
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Orm\Contracts;
interface ConnectionInterface
{
public function connect(): void;
public function isConnected(): bool;
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Orm;
use Phred\Orm\Contracts\ConnectionInterface;
use Pairity\Manager;
final class PairityConnection implements ConnectionInterface
{
private bool $connected = false;
private ?Manager $manager = null;
public function connect(): void
{
$this->connected = true;
$this->manager = new Manager();
}
public function isConnected(): bool
{
return $this->connected;
}
public function getManager(): Manager
{
if (!$this->manager) {
$this->connect();
}
return $this->manager;
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers;
use DI\Container;
use DI\ContainerBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Routing\RouteRegistry;
use Phred\Http\Router;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class AppServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
// Place app-specific bindings here as needed.
}
public function boot(Container $container): void
{
// Demonstrate adding a route from a provider
RouteRegistry::add(static function ($collector, Router $router): void {
$router->get('/_phred/app', static function () {
$psr17 = new Psr17Factory();
$res = $psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
$res->getBody()->write(json_encode(['app' => true], JSON_UNESCAPED_SLASHES));
return $res;
});
});
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class FlagsServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) $config->get('FLAGS_DRIVER', $config->get('app.drivers.flags', 'flagpole'));
$impl = match ($driver) {
'flagpole' => \Phred\Flags\FlagpoleClient::class,
default => throw new \RuntimeException("Unsupported flags driver: {$driver}"),
};
if ($driver === 'flagpole' && !class_exists(\Flagpole\FeatureManager::class)) {
throw new \RuntimeException("Flagpole FeatureManager not found. Did you install getphred/flagpole?");
}
$builder->addDefinitions([
\Phred\Flags\Contracts\FeatureFlagClientInterface::class => \DI\autowire($impl),
]);
}
public function boot(Container $container): void {}
}

View file

@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\MessageFormatter;
use Phred\Support\Http\CircuitBreakerMiddleware;
use Phred\Http\Middleware\Middleware as PhredMiddleware;
use Psr\SimpleCache\CacheInterface;
use Phred\Support\Cache\FileCache;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Log\LoggerInterface;
final class HttpServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$builder->addDefinitions([
CacheInterface::class => function (ConfigInterface $config) {
$cacheDir = getcwd() . '/storage/cache';
return new FileCache($cacheDir);
},
ClientInterface::class => function (ConfigInterface $config, Container $c) {
$options = $config->get('http.client', [
'timeout' => 5.0,
'connect_timeout' => 2.0,
]);
$stack = HandlerStack::create();
// Profiling middleware
$stack->push(function (callable $handler) {
return function (\Psr\Http\Message\RequestInterface $request, array $options) use ($handler) {
$start = microtime(true);
return $handler($request, $options)->then(
function ($response) use ($start, $request) {
$duration = microtime(true) - $start;
$host = $request->getUri()->getHost();
$key = "HTTP: " . $host;
PhredMiddleware::recordTiming($key, $duration);
return $response;
},
function ($reason) use ($start, $request) {
$duration = microtime(true) - $start;
$host = $request->getUri()->getHost();
$key = "HTTP: " . $host;
PhredMiddleware::recordTiming($key, $duration);
return \GuzzleHttp\Promise\Create::rejectionFor($reason);
}
);
};
}, 'profiler');
// Logging middleware
if ($config->get('http.middleware.log', false)) {
try {
$logger = $c->get(LoggerInterface::class);
$stack->push(Middleware::log(
$logger,
new MessageFormatter(MessageFormatter::SHORT)
));
} catch (\Throwable) {
// Logger not available, skip logging middleware
}
}
// Retry middleware
if ($config->get('http.middleware.retry.enabled', false)) {
$maxRetries = $config->get('http.middleware.retry.max_retries', 3);
$stack->push(Middleware::retry(function ($retries, $request, $response, $exception) use ($maxRetries) {
if ($retries >= $maxRetries) {
return false;
}
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
return true;
}
if ($response && $response->getStatusCode() >= 500) {
return true;
}
return false;
}));
}
// Circuit Breaker middleware
if ($config->get('http.middleware.circuit_breaker.enabled', false)) {
$threshold = $config->get('http.middleware.circuit_breaker.threshold', 5);
$timeout = $config->get('http.middleware.circuit_breaker.timeout', 30.0);
$cache = $c->get(CacheInterface::class);
$stack->push(new CircuitBreakerMiddleware($threshold, (float) $timeout, $cache));
}
$options['handler'] = $stack;
return new Client($options);
},
]);
}
public function boot(Container $container): void {}
}

View file

@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogHandler;
use Monolog\Handler\SlackWebhookHandler;
use Monolog\Logger;
use Monolog\LogRecord;
use Monolog\Processor\MemoryUsageProcessor;
use Monolog\Processor\ProcessIdProcessor;
use Nyholm\Psr7\ServerRequest;
use Phred\Http\Contracts\RequestIdProviderInterface;
use Phred\Http\Support\DefaultRequestIdProvider;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
use Psr\Log\LoggerInterface;
final class LoggingServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$builder->addDefinitions([
LoggerInterface::class => function (Container $c, ConfigInterface $config) {
$name = (string) $config->get('APP_NAME', 'Phred');
$defaultChannel = (string) $config->get('logging.default', 'stack');
$logger = new Logger($name);
$this->createChannel($logger, $defaultChannel, $config);
// Processors
$logger->pushProcessor(new ProcessIdProcessor());
$logger->pushProcessor(new MemoryUsageProcessor());
$logger->pushProcessor(function (LogRecord $record) use ($c): LogRecord {
try {
$requestIdProvider = $c->get(RequestIdProviderInterface::class);
// We need a request to provide an ID, but logger might be called outside of a request.
// Try to get request from container if available, or use dummy.
$request = $c->has('request') ? $c->get('request') : new ServerRequest('GET', '/');
$id = $requestIdProvider->provide($request);
} catch (\Throwable) {
$id = bin2hex(random_bytes(8));
}
$record->extra['request_id'] = $id;
return $record;
});
return $logger;
},
]);
}
private function createChannel(Logger $logger, string $channel, ConfigInterface $config): void
{
$channelConfig = $config->get("logging.channels.$channel");
if (!$channelConfig) {
// Fallback to a basic single log if channel not found
$logDir = getcwd() . '/storage/logs';
if (!is_dir($logDir)) {
@mkdir($logDir, 0777, true);
}
$logger->pushHandler(new StreamHandler($logDir . '/phred.log'));
return;
}
$driver = $channelConfig['driver'] ?? 'single';
switch ($driver) {
case 'stack':
foreach ($channelConfig['channels'] ?? [] as $subChannel) {
$this->createChannel($logger, $subChannel, $config);
}
break;
case 'single':
$this->ensureDir(dirname($channelConfig['path']));
$logger->pushHandler(new StreamHandler($channelConfig['path'], $channelConfig['level'] ?? 'debug'));
break;
case 'daily':
$this->ensureDir(dirname($channelConfig['path']));
$logger->pushHandler(new RotatingFileHandler(
$channelConfig['path'],
$channelConfig['days'] ?? 7,
$channelConfig['level'] ?? 'debug'
));
break;
case 'syslog':
$logger->pushHandler(new SyslogHandler($config->get('APP_NAME', 'Phred'), LOG_USER, $channelConfig['level'] ?? 'debug'));
break;
case 'errorlog':
$logger->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $channelConfig['level'] ?? 'debug'));
break;
case 'slack':
$this->createSlackHandler($logger, $channelConfig);
break;
case 'sentry':
$this->createSentryHandler($logger, $channelConfig);
break;
}
}
private function createSlackHandler(Logger $logger, array $config): void
{
if (!class_exists(SlackWebhookHandler::class)) {
// Silently skip if Monolog Slack handler is missing (usually bundled with Monolog 2/3)
return;
}
if (empty($config['url'])) {
return;
}
$logger->pushHandler(new SlackWebhookHandler(
$config['url'],
$config['channel'] ?? null,
$config['username'] ?? 'Phred Log Bot',
true,
null,
false,
true,
$config['level'] ?? 'critical'
));
}
private function createSentryHandler(Logger $logger, array $config): void
{
// Using sentry/sentry-monolog if available
if (!class_exists(\Sentry\Monolog\Handler::class) || !class_exists(\Sentry\SentrySdk::class)) {
return;
}
if (empty($config['dsn'])) {
return;
}
\Sentry\init(['dsn' => $config['dsn']]);
$logger->pushHandler(new \Sentry\Monolog\Handler(\Sentry\SentrySdk::getCurrentHub(), $config['level'] ?? 'error'));
}
private function ensureDir(string $path): void
{
if (!is_dir($path)) {
@mkdir($path, 0777, true);
}
}
public function boot(Container $container): void {}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class OrmServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) $config->get('ORM_DRIVER', $config->get('app.drivers.orm', 'pairity'));
$impl = match ($driver) {
'pairity' => \Phred\Orm\PairityConnection::class,
'eloquent' => \Phred\Orm\EloquentConnection::class, // Future proofing or assuming it might be added
default => throw new \RuntimeException("Unsupported ORM driver: {$driver}"),
};
// Validate dependencies for the driver
if ($driver === 'pairity' && !class_exists(\Pairity\Manager::class)) {
throw new \RuntimeException("Pairity Manager not found. Did you install getphred/pairity?");
}
$builder->addDefinitions([
\Phred\Orm\Contracts\ConnectionInterface::class => \DI\autowire($impl),
]);
}
public function boot(Container $container): void {}
}

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Http\Routing\RouteRegistry;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class RoutingServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
// No bindings required; route registry is static helper for now.
}
public function boot(Container $container): void
{
// Core routes can be appended here in future if needed.
// Keeping provider to illustrate ordering and future extension point.
RouteRegistry::add(static function (): void {
// no-op
});
}
}

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Security\Contracts\TokenServiceInterface;
use Phred\Security\Jwt\JwtTokenService;
use Phred\Flags\Contracts\FeatureFlagClientInterface;
use Phred\Flags\FlagpoleClient;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class SecurityServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) $config->get('AUTH_DRIVER', 'jwt');
$impl = match ($driver) {
'jwt' => \Phred\Security\Jwt\JwtTokenService::class,
default => throw new \RuntimeException("Unsupported auth driver: {$driver}"),
};
if ($driver === 'jwt' && !class_exists(\Lcobucci\JWT\Configuration::class)) {
throw new \RuntimeException("lcobucci/jwt not found. Did you install lcobucci/jwt?");
}
$builder->addDefinitions([
TokenServiceInterface::class => \DI\autowire($impl),
]);
}
public function boot(Container $container): void {}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
final class SerializationServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$builder->addDefinitions([
SerializerInterface::class => function () {
$encoders = [new XmlEncoder(), new JsonEncoder()];
// Avoid using ObjectNormalizer if symfony/property-access is missing
// Or use it only if available. For now, let's use ArrayDenormalizer which is safer
$normalizers = [new ArrayDenormalizer()];
return new Serializer($normalizers, $encoders);
},
]);
}
public function boot(Container $container): void {}
}

View file

@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use Aws\S3\S3Client;
use Phred\Support\Storage\StorageManager;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class StorageServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$builder->addDefinitions([
StorageManager::class => function (Container $c) {
$config = $c->get(ConfigInterface::class);
$storageConfig = $config->get('storage');
$defaultFilesystem = $c->get(FilesystemOperator::class);
return new StorageManager($defaultFilesystem, $storageConfig);
},
FilesystemOperator::class => \DI\get(Filesystem::class),
Filesystem::class => function (Container $c) {
$config = $c->get(ConfigInterface::class);
$default = $config->get('storage.default', 'local');
$diskConfig = $config->get("storage.disks.$default");
if (!$diskConfig) {
throw new \RuntimeException("Storage disk [$default] is not configured.");
}
$driver = $diskConfig['driver'] ?? 'local';
$adapter = match ($driver) {
'local' => new LocalFilesystemAdapter($diskConfig['root']),
's3' => $this->createS3Adapter($diskConfig),
default => throw new \RuntimeException("Unsupported storage driver [$driver]."),
};
return new Filesystem($adapter);
},
]);
}
private function createS3Adapter(array $config): AwsS3V3Adapter
{
if (!class_exists(S3Client::class)) {
throw new \RuntimeException("AWS SDK not found. Did you install aws/aws-sdk-php?");
}
if (!class_exists(AwsS3V3Adapter::class)) {
throw new \RuntimeException("Flysystem S3 adapter not found. Did you install league/flysystem-aws-s3-v3?");
}
$clientConfig = [
'credentials' => [
'key' => $config['key'],
'secret' => $config['secret'],
],
'region' => $config['region'],
'version' => 'latest',
];
if (!empty($config['endpoint'])) {
$clientConfig['endpoint'] = $config['endpoint'];
$clientConfig['use_path_style_endpoint'] = $config['use_path_style_endpoint'] ?? false;
}
$client = new S3Client($clientConfig);
return new AwsS3V3Adapter($client, $config['bucket']);
}
public function boot(Container $container): void {}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class TemplateServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) $config->get('TEMPLATE_DRIVER', $config->get('app.drivers.template', 'eyrie'));
$impl = match ($driver) {
'eyrie' => \Phred\Template\EyrieRenderer::class,
default => throw new \RuntimeException("Unsupported template driver: {$driver}"),
};
if ($driver === 'eyrie' && !class_exists(\Eyrie\Engine::class)) {
throw new \RuntimeException("Eyrie Engine not found. Did you install getphred/eyrie?");
}
$builder->addDefinitions([
\Phred\Template\Contracts\RendererInterface::class => \DI\autowire($impl),
]);
}
public function boot(Container $container): void {}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class TestingServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) $config->get('TEST_RUNNER', $config->get('app.drivers.test_runner', 'codeception'));
$impl = match ($driver) {
'codeception' => \Phred\Testing\CodeceptionRunner::class,
default => throw new \RuntimeException("Unsupported test runner driver: {$driver}"),
};
if ($driver === 'codeception' && !class_exists(\Codeception\Codecept::class)) {
throw new \RuntimeException("Codeception not found. Did you install codeception/codeception?");
}
$builder->addDefinitions([
\Phred\Testing\Contracts\TestRunnerInterface::class => \DI\autowire($impl),
]);
}
public function boot(Container $container): void {}
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Security\Contracts;
/**
* Contract for JWT token generation and verification.
*/
interface TokenServiceInterface
{
/**
* Create a new JWT for the given user identifier.
*
* @param string|int $userId
* @param array $claims Additional claims
* @return string
*/
public function createToken(string|int $userId, array $claims = []): string;
/**
* Parse and validate a JWT string.
*
* @param string $token
* @return array Claims from the token
* @throws \Exception if token is invalid
*/
public function validateToken(string $token): array;
}

View file

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Security\Jwt;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Phred\Security\Contracts\TokenServiceInterface;
use Phred\Support\Contracts\ConfigInterface;
/**
* JWT implementation using lcobucci/jwt.
*/
final class JwtTokenService implements TokenServiceInterface
{
private Configuration $config;
public function __construct(ConfigInterface $appConfig)
{
$key = (string) $appConfig->get('jwt.secret', 'change-me-to-something-very-secure');
$this->config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText($key)
);
$this->config->setValidationConstraints(
new SignedWith($this->config->signer(), $this->config->signingKey())
);
}
public function createToken(string|int $userId, array $claims = []): string
{
$now = new \DateTimeImmutable();
$builder = $this->config->builder()
->issuedBy((string) getenv('APP_URL'))
->permittedFor((string) getenv('APP_URL'))
->identifiedBy(bin2hex(random_bytes(16)))
->issuedAt($now)
->canOnlyBeUsedAfter($now)
->expiresAt($now->modify('+1 hour'))
->withClaim('uid', $userId);
foreach ($claims as $name => $value) {
$builder = $builder->withClaim($name, $value);
}
return $builder->getToken($this->config->signer(), $this->config->signingKey())->toString();
}
public function validateToken(string $token): array
{
$jwt = $this->config->parser()->parse($token);
$constraints = $this->config->validationConstraints();
if (!$this->config->validator()->validate($jwt, ...$constraints)) {
throw new \RuntimeException('Invalid JWT');
}
if (!$jwt instanceof UnencryptedToken) {
throw new \RuntimeException('Parsed JWT is not an unencrypted token');
}
return $jwt->claims()->all();
}
}

View file

@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Cache;
use Psr\SimpleCache\CacheInterface;
final class FileCache implements CacheInterface
{
public function __construct(private readonly string $directory)
{
if (!is_dir($this->directory)) {
@mkdir($this->directory, 0777, true);
}
}
public function get(string $key, mixed $default = null): mixed
{
$file = $this->getFilePath($key);
if (!file_exists($file)) {
return $default;
}
$content = file_get_contents($file);
if ($content === false) {
return $default;
}
$data = unserialize($content);
if ($data['expires'] !== 0 && $data['expires'] < time()) {
@unlink($file);
return $default;
}
return $data['value'];
}
public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
{
$expires = 0;
if ($ttl !== null) {
if ($ttl instanceof \DateInterval) {
$expires = (new \DateTime())->add($ttl)->getTimestamp();
} else {
$expires = time() + $ttl;
}
}
$data = [
'expires' => $expires,
'value' => $value,
];
return file_put_contents($this->getFilePath($key), serialize($data)) !== false;
}
public function delete(string $key): bool
{
$file = $this->getFilePath($key);
if (file_exists($file)) {
return @unlink($file);
}
return true;
}
public function clear(): bool
{
foreach (glob($this->directory . '/*') as $file) {
if (is_file($file)) {
@unlink($file);
}
}
return true;
}
public function getMultiple(iterable $keys, mixed $default = null): iterable
{
$result = [];
foreach ($keys as $key) {
$result[$key] = $this->get($key, $default);
}
return $result;
}
public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool
{
foreach ($values as $key => $value) {
$this->set($key, $value, $ttl);
}
return true;
}
public function deleteMultiple(iterable $keys): bool
{
foreach ($keys as $key) {
$this->delete($key);
}
return true;
}
public function has(string $key): bool
{
return $this->get($key, $this) !== $this;
}
private function getFilePath(string $key): string
{
return $this->directory . '/' . md5($key) . '.cache';
}
}

View file

@ -1,101 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support;
final class Config
{
/** @var array<string,mixed>|null */
private static ?array $store = null;
/**
* Get configuration value with precedence:
* 1) Environment variables (UPPER_CASE or dot.notation translated)
* 2) Loaded config files from config/*.php, accessible via dot.notation (e.g., app.env)
* 3) Provided $default
*/
public static function get(string $key, mixed $default = null): mixed
{
// 1) Environment lookup (supports dot.notation by converting to UPPER_SNAKE)
$envKey = strtoupper(str_replace('.', '_', $key));
$value = getenv($envKey);
if ($value !== false) {
return $value;
}
if (isset($_SERVER[$envKey])) {
return $_SERVER[$envKey];
}
if (isset($_ENV[$envKey])) {
return $_ENV[$envKey];
}
// 2) Config files (lazy load once)
self::ensureLoaded();
if (self::$store) {
$fromStore = self::getFromStore($key);
if ($fromStore !== null) {
return $fromStore;
}
}
// 3) Default
return $default;
}
private static function ensureLoaded(): void
{
if (self::$store !== null) {
return;
}
self::$store = [];
$root = getcwd();
$configDir = $root . DIRECTORY_SEPARATOR . 'config';
if (!is_dir($configDir)) {
return; // no config directory; keep empty store
}
foreach (glob($configDir . '/*.php') ?: [] as $file) {
$key = basename($file, '.php');
try {
$data = require $file;
if (is_array($data)) {
self::$store[$key] = $data;
}
} catch (\Throwable) {
// ignore malformed config files to avoid breaking runtime
}
}
}
private static function getFromStore(string $key): mixed
{
// dot.notation: first segment is file key, remaining traverse array
if (str_contains($key, '.')) {
$parts = explode('.', $key);
$rootKey = array_shift($parts);
if ($rootKey === null || !isset(self::$store[$rootKey])) {
return null;
}
$cursor = self::$store[$rootKey];
foreach ($parts as $p) {
if (is_array($cursor) && array_key_exists($p, $cursor)) {
$cursor = $cursor[$p];
} else {
return null;
}
}
return $cursor;
}
// non-dotted: try exact file key
return self::$store[$key] ?? null;
}
/**
* Clear the config store (useful for tests).
*/
public static function clear(): void
{
self::$store = null;
}
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Contracts;
interface ConfigInterface
{
/**
* Retrieve a configuration value by key.
* Supports dot.notation keys. Implementations define precedence.
*/
public function get(string $key, mixed $default = null): mixed;
}

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Contracts;
use DI\Container;
use DI\ContainerBuilder;
/**
* Service providers can register bindings before the container is built
* and perform boot-time work after the container is available.
*/
interface ServiceProviderInterface
{
/**
* Register container definitions/bindings. Called before the container is built.
*/
public function register(ContainerBuilder $builder, ConfigInterface $config): void;
/**
* Boot after the container has been built. Safe to resolve services here.
*/
public function boot(Container $container): void;
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support;
use Phred\Support\Contracts\ConfigInterface;
/**
* Default adapter that delegates to the legacy static Config facade.
*/
final class DefaultConfig implements ConfigInterface
{
public function get(string $key, mixed $default = null): mixed
{
return Config::get($key, $default);
}
}

View file

@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Http;
use GuzzleHttp\Promise\Create;
use Psr\Http\Message\RequestInterface;
use Psr\SimpleCache\CacheInterface;
/**
* A circuit breaker middleware for Guzzle with optional PSR-16 persistence.
*/
final class CircuitBreakerMiddleware
{
private static array $localFailures = [];
private static array $localLastFailureTime = [];
private static array $localIsOpen = [];
public function __construct(
private readonly int $threshold = 5,
private readonly float $timeout = 30.0,
private readonly ?CacheInterface $cache = null
) {}
public function __invoke(callable $handler): callable
{
return function (RequestInterface $request, array $options) use ($handler) {
$host = $request->getUri()->getHost();
if ($this->isCircuitOpen($host)) {
return Create::rejectionFor(
new \RuntimeException("Circuit breaker is open for host: $host")
);
}
return $handler($request, $options)->then(
function ($response) use ($host) {
if ($response instanceof \Psr\Http\Message\ResponseInterface && $response->getStatusCode() >= 500) {
$this->reportFailure($host);
} else {
$this->reportSuccess($host);
}
return $response;
},
function ($reason) use ($host) {
$this->reportFailure($host);
return Create::rejectionFor($reason);
}
);
};
}
private function isCircuitOpen(string $host): bool
{
$state = $this->getState($host);
if (!$state['isOpen']) {
return false;
}
if ((microtime(true) - $state['lastFailureTime']) > $this->timeout) {
// Half-open state in a real CB, here we just try again
$this->reportSuccess($host);
return false;
}
return true;
}
private function reportSuccess(string $host): void
{
$this->saveState($host, [
'failures' => 0,
'lastFailureTime' => 0,
'isOpen' => false,
]);
}
private function reportFailure(string $host): void
{
$state = $this->getState($host);
$state['failures']++;
$state['lastFailureTime'] = microtime(true);
if ($state['failures'] >= $this->threshold) {
$state['isOpen'] = true;
}
$this->saveState($host, $state);
}
private function getState(string $host): array
{
if ($this->cache) {
return $this->cache->get("cb.$host", [
'failures' => 0,
'lastFailureTime' => 0,
'isOpen' => false,
]);
}
return [
'failures' => self::$localFailures[$host] ?? 0,
'lastFailureTime' => self::$localLastFailureTime[$host] ?? 0,
'isOpen' => self::$localIsOpen[$host] ?? false,
];
}
private function saveState(string $host, array $state): void
{
if ($this->cache) {
$this->cache->set("cb.$host", $state, (int)$this->timeout * 2);
return;
}
self::$localFailures[$host] = $state['failures'];
self::$localLastFailureTime[$host] = $state['lastFailureTime'];
self::$localIsOpen[$host] = $state['isOpen'];
}
public static function clear(string $host = null): void
{
if ($host) {
unset(self::$localFailures[$host], self::$localLastFailureTime[$host], self::$localIsOpen[$host]);
} else {
self::$localFailures = [];
self::$localLastFailureTime = [];
self::$localIsOpen = [];
}
}
}

View file

@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
/**
* Loads and executes service providers in deterministic order.
* Order: core app modules
*/
final class ProviderRepository
{
/** @var list<ServiceProviderInterface> */
private array $providers = [];
public function __construct(private readonly ConfigInterface $config)
{
}
public function load(): void
{
$this->providers = [];
// Merge providers from config/providers.php file (authoritative) with any runtime Config entries
$fileCore = $fileApp = $fileModules = [];
$configFile = dirname(__DIR__, 2) . '/config/providers.php';
if (is_file($configFile)) {
/** @noinspection PhpIncludeInspection */
$arr = require $configFile;
if (is_array($arr)) {
$fileCore = (array)($arr['core'] ?? []);
$fileApp = (array)($arr['app'] ?? []);
$fileModules = (array)($arr['modules'] ?? []);
}
}
$core = array_values(array_unique(array_merge($fileCore, (array) Config::get('providers.core', []))));
$app = array_values(array_unique(array_merge($fileApp, (array) Config::get('providers.app', []))));
$modules = array_values(array_unique(array_merge($fileModules, (array) Config::get('providers.modules', []))));
foreach ([$core, $app, $modules] as $group) {
foreach ($group as $class) {
if (is_string($class) && class_exists($class)) {
$instance = new $class();
if ($instance instanceof ServiceProviderInterface) {
$this->providers[] = $instance;
}
}
}
}
// Initial module discovery: scan modules/*/Providers/*ServiceProvider.php
$root = dirname(__DIR__, 2);
$modulesDir = $root . '/modules';
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$modulePath = $modulesDir . '/' . $entry;
if (!is_dir($modulePath)) {
continue;
}
$providersPath = $modulePath . '/Providers';
if (!is_dir($providersPath)) {
continue;
}
foreach (scandir($providersPath) ?: [] as $file) {
if ($file === '.' || $file === '..' || !str_ends_with($file, '.php')) {
continue;
}
$classBase = substr($file, 0, -4);
if (!str_ends_with($classBase, 'ServiceProvider')) {
continue;
}
$fqcn = "Project\\\\Modules\\\\{$entry}\\\\Providers\\\\{$classBase}";
if (class_exists($fqcn)) {
$instance = new $fqcn();
if ($instance instanceof ServiceProviderInterface) {
$this->providers[] = $instance;
}
}
}
}
}
}
public function registerAll(ContainerBuilder $builder): void
{
foreach ($this->providers as $provider) {
$provider->register($builder, $this->config);
}
}
public function bootAll(Container $container): void
{
foreach ($this->providers as $provider) {
$provider->boot($container);
}
}
}

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Storage;
use League\Flysystem\FilesystemOperator;
final class StorageManager
{
/** @var array<string, FilesystemOperator> */
private array $disks = [];
public function __construct(
private readonly FilesystemOperator $defaultDisk,
private readonly array $config = []
) {}
public function disk(?string $name = null): FilesystemOperator
{
if ($name === null) {
return $this->defaultDisk;
}
return $this->disks[$name] ?? $this->defaultDisk;
}
public function url(string $path, ?string $disk = null): string
{
$diskName = $disk ?? 'local';
$diskConfig = $this->config['disks'][$diskName] ?? null;
if (!$diskConfig || empty($diskConfig['url'])) {
return $path;
}
return rtrim($diskConfig['url'], '/') . '/' . ltrim($path, '/');
}
}

View file

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Template\Contracts;
interface RendererInterface
{
/**
* Render a template with provided data into a string.
* Implementation detail depends on selected driver.
*/
public function render(string $template, array $data = []): string;
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Template;
use Phred\Template\Contracts\RendererInterface;
/**
* Minimal placeholder renderer used as default driver.
*/
final class EyrieRenderer implements RendererInterface
{
public function render(string $template, array $data = []): string
{
// naive replacement for demo purposes
$out = $template;
foreach ($data as $k => $v) {
$out = str_replace('{{' . $k . '}}', (string) $v, $out);
}
return $out;
}
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Testing;
use Phred\Testing\Contracts\TestRunnerInterface;
final class CodeceptionRunner implements TestRunnerInterface
{
public function run(?string $suite = null): int
{
// placeholder implementation always succeeds
return 0;
}
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Testing\Contracts;
interface TestRunnerInterface
{
/**
* Run tests and return exit code (0 success).
*/
public function run(?string $suite = null): int;
}

View file

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:command';
protected string $description = 'Scaffold a new CLI command in console/commands.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Command name (e.g., hello:world)',
],
'--description' => [
'mode' => 'option',
'valueRequired' => true,
'description' => 'Optional command description.',
],
];
public function handle(Input $input, Output $output): int
{
$name = trim((string) $input->getArgument('name'));
$description = $input->getOption('description') ?: 'Custom CLI command.';
if ($name === '') {
$output->writeln('<error>Command name is required.</error>');
return 1;
}
$root = getcwd();
$commandsDir = $root . '/console/commands';
if (!is_dir($commandsDir)) {
@mkdir($commandsDir, 0777, true);
}
// Convert name to StudlyCase for filename, e.g., hello:world -> HelloWorld.php
$filename = str_replace([':', '-', '_'], ' ', $name);
$filename = str_replace(' ', '', ucwords($filename)) . '.php';
$path = $commandsDir . '/' . $filename;
if (file_exists($path)) {
$output->writeln("<error>Command file '$filename' already exists.</error>");
return 1;
}
$stub = file_get_contents(dirname(__DIR__) . '/stubs/command.stub');
$code = strtr($stub, [
'{{namespace}}' => '', // Global namespace for console/commands by default or project specific?
// bin/phred uses anonymous class require, so namespace is optional but good for structure.
'{{command}}' => $name,
'{{description}}' => $description,
]);
// Remove empty namespace line if present
$code = str_replace("namespace ;\n\n", "", $code);
file_put_contents($path, $code);
$output->writeln("<info>created</info> console/commands/$filename");
return 0;
}
};

View file

@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:controller';
protected string $description = 'Scaffold a new controller in a module.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Controller name (e.g., PostController)',
],
'module' => [
'mode' => 'argument',
'required' => false,
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:controller',
],
'--view' => [
'mode' => 'option',
'valueRequired' => true,
'description' => 'Optional View class name to associate with this controller.',
],
];
public function handle(Input $input, Output $output): int
{
$module = null;
if (preg_match('/^create:([^:]+):controller$/', $this->getName(), $matches)) {
$module = $matches[1];
}
if (!$module) {
$module = $input->hasArgument('module') ? $input->getArgument('module') : null;
}
$module = trim((string) $module);
$name = trim((string) $input->getArgument('name'));
$viewClass = $input->getOption('view') ? trim((string) $input->getOption('view')) : null;
if ($module === '' || $name === '') {
$output->writeln('<error>Module and Name are required.</error>');
return 1;
}
// Case-insensitive module directory lookup
$modulesDir = getcwd() . '/modules';
$moduleDir = null;
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $dir) {
if (strtolower($dir) === strtolower($module)) {
$moduleDir = $modulesDir . '/' . $dir;
$module = $dir; // Use actual casing
break;
}
}
}
if (!$moduleDir || !is_dir($moduleDir)) {
$output->writeln("<error>Module '$module' does not exist.</error>");
return 1;
}
$controllersDir = $moduleDir . '/Controllers';
if (!is_dir($controllersDir)) {
@mkdir($controllersDir, 0777, true);
}
$filename = $name . '.php';
$path = $controllersDir . '/' . $filename;
if (file_exists($path)) {
$output->writeln("<error>Controller '$name' already exists in module '$module'.</error>");
return 1;
}
$namespace = "Project\\Modules\\$module\\Controllers";
$viewUse = '';
$invokeParams = 'Request $request';
$renderBody = " return (new \Nyholm\Psr7\Factory\Psr17Factory())
->createResponse(200)
->withHeader('Content-Type', 'text/plain')
->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('$name ready'));";
if ($viewClass) {
$viewFqcn = "Project\\Modules\\$module\\Views\\$viewClass";
$viewUse = "use $viewFqcn;";
$invokeParams = "Request \$request, $viewClass \$view";
$renderBody = " return \$this->renderView(\$view, []);";
}
$stub = file_get_contents(dirname(__DIR__) . '/stubs/controller.stub');
$template = strtr($stub, [
'{{namespace}}' => $namespace,
'{{useView}}' => $viewUse,
'{{class}}' => $name,
'{{params}}' => $invokeParams,
'{{body}}' => $renderBody,
]);
file_put_contents($path, $template);
$output->writeln("<info>Controller '$name' created</info> at modules/$module/Controllers/$filename");
return 0;
}
};

View file

@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:migration';
protected string $description = 'Scaffold a new migration in a module.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Migration name (e.g., CreatePostsTable)',
],
'module' => [
'mode' => 'argument',
'required' => false,
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:migration',
],
];
public function handle(Input $input, Output $output): int
{
$module = null;
if (preg_match('/^create:([^:]+):migration$/', $this->getName(), $matches)) {
$module = $matches[1];
}
if (!$module) {
$module = $input->hasArgument('module') ? $input->getArgument('module') : null;
}
$module = trim((string) $module);
$name = trim((string) $input->getArgument('name'));
if ($module === '' || $name === '') {
$output->writeln('<error>Module and Name are required.</error>');
return 1;
}
// Case-insensitive module directory lookup
$modulesDir = getcwd() . '/modules';
$moduleDir = null;
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $dir) {
if (strtolower($dir) === strtolower($module)) {
$moduleDir = $modulesDir . '/' . $dir;
$module = $dir; // Use actual casing
break;
}
}
}
if (!$moduleDir || !is_dir($moduleDir)) {
$output->writeln("<error>Module '$module' does not exist.</error>");
return 1;
}
$migrationsDir = $moduleDir . '/Database/Migrations';
if (!is_dir($migrationsDir)) {
@mkdir($migrationsDir, 0777, true);
}
$timestamp = date('Y_m_d_His');
$filename = $timestamp . '_' . strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name)) . '.php';
$path = $migrationsDir . '/' . $filename;
$template = file_get_contents(dirname(__DIR__) . '/stubs/migration.stub');
file_put_contents($path, $template);
$output->writeln("<info>Migration created</info> at modules/$module/Database/Migrations/$filename");
return 0;
}
};

View file

@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:model';
protected string $description = 'Scaffold a new model in a module.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Model name (e.g., Post)',
],
'module' => [
'mode' => 'argument',
'required' => false,
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:model',
],
];
public function handle(Input $input, Output $output): int
{
$module = null;
if (preg_match('/^create:([^:]+):model$/', $this->getName(), $matches)) {
$module = $matches[1];
}
if (!$module) {
$module = $input->hasArgument('module') ? $input->getArgument('module') : null;
}
$module = trim((string) $module);
$name = trim((string) $input->getArgument('name'));
if ($module === '' || $name === '') {
$output->writeln('<error>Module and Name are required.</error>');
return 1;
}
// Case-insensitive module directory lookup
$modulesDir = getcwd() . '/modules';
$moduleDir = null;
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $dir) {
if (strtolower($dir) === strtolower($module)) {
$moduleDir = $modulesDir . '/' . $dir;
$module = $dir; // Use actual casing
break;
}
}
}
if (!$moduleDir || !is_dir($moduleDir)) {
$output->writeln("<error>Module '$module' does not exist.</error>");
return 1;
}
$modelsDir = $moduleDir . '/Models';
if (!is_dir($modelsDir)) {
@mkdir($modelsDir, 0777, true);
}
$path = $modelsDir . '/' . $name . '.php';
if (file_exists($path)) {
$output->writeln("<error>Model '$name' already exists in module '$module'.</error>");
return 1;
}
$namespace = "Project\\Modules\\$module\\Models";
$stub = file_get_contents(dirname(__DIR__) . '/stubs/model.stub');
$template = strtr($stub, [
'{{namespace}}' => $namespace,
'{{class}}' => $name,
]);
file_put_contents($path, $template);
$output->writeln("<info>Model '$name' created</info> at modules/$module/Models/$name.php");
return 0;
}
};

View file

@ -1,383 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:module';
protected string $description = 'Scaffold a new Module with routes, provider, and optional PSR-4 setup.';
protected array $options = [
// Match existing command style: arguments are declared as simple keys without leading dashes
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Module name (e.g., Blog)',
],
'prefix' => [
'mode' => 'argument',
'required' => false,
'description' => 'Optional URL prefix (e.g., /blog). If omitted, you will be prompted or default is /<name-lower>',
],
'--update-composer' => [
'mode' => 'flag',
'description' => 'Automatically add PSR-4 mapping to composer.json and dump autoload.',
],
'--no-dump' => [
'mode' => 'flag',
'description' => 'Skip composer dump-autoload when using --update-composer.',
],
];
public function handle(Input $input, Output $output): int
{
$name = $this->readArgWithFallback($input, 'name', 2);
$name = trim($name);
if ($name === '') {
$output->writeln('<error>Module name is required.</error>');
return 1;
}
$prefixArg = $this->readArgWithFallback($input, 'prefix', 3);
[$prefix, $updateComposer, $noDump] = $this->parseArgsForPrefixAndFlags($prefixArg, $name, $input);
$root = dirname(__DIR__, 2);
$moduleRoot = $root . '/modules/' . $name;
if (!$this->createScaffold($moduleRoot, $output)) {
return 1;
}
$this->createPersistenceDir($moduleRoot, $output);
$this->writeProviderStub($moduleRoot, $name);
$this->writeRoutesStubs($moduleRoot, $name);
$this->writeViewControllerTemplateStubs($moduleRoot, $name);
$this->writeControllersIndexIfMissing($moduleRoot);
// Ensure Controllers directory exists specifically for the test assertion
if (!is_dir($moduleRoot . '/Controllers')) {
@mkdir($moduleRoot . '/Controllers', 0777, true);
}
$this->registerProviderInConfig($root, $name);
$this->appendRouteInclude($root, $name, $prefix);
$this->printPsr4Hint($output, $name, $prefix);
if ($updateComposer) {
$this->updateComposerPsr4($output, $root, $name, !$noDump);
}
$output->writeln("\n<info>Full documentation available at:</info> https://getphred.com");
return 0;
}
private function readArgWithFallback(Input $input, string $key, int $argvIndex): string
{
try {
$val = $input->getArgument($key);
if (is_string($val)) {
return $val;
}
} catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {
// Try to read from ArrayInput-style arguments map
if (method_exists($input, 'getArguments')) {
$args = $input->getArguments();
if (is_array($args) && isset($args[$key]) && is_string($args[$key])) {
return $args[$key];
}
}
// Reflection fallback for ArrayInput to read raw parameters
if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) {
try {
$ref = new \ReflectionObject($input);
if ($ref->hasProperty('parameters')) {
$prop = $ref->getProperty('parameters');
$prop->setAccessible(true);
$params = $prop->getValue($input);
if (is_array($params) && isset($params[$key]) && is_string($params[$key])) {
return $params[$key];
}
}
} catch (\Throwable) {
// ignore
}
}
// Fall back to argv position if command definition didn't register arguments in this context (e.g., direct handle() calls in tests)
$argv = $_SERVER['argv'] ?? [];
$fallback = isset($argv[$argvIndex]) ? (string) $argv[$argvIndex] : '';
// Sanitize: ignore flags (starting with '-') and unexpected tokens
if ($fallback !== '' && $fallback[0] === '-') {
return '';
}
return $fallback;
}
return '';
}
private function parseArgsForPrefixAndFlags(string $prefixArg, string $name, Input $input): array
{
$defaultPrefix = '/' . strtolower($name);
// Detect flags robustly
$updateComposer = false;
$noDump = false;
// Attempt to read flags from options map if available
if (method_exists($input, 'getOptions')) {
$opts = $input->getOptions();
if (is_array($opts)) {
$updateComposer = !empty($opts['--update-composer']) || !empty($opts['update-composer']);
$noDump = !empty($opts['--no-dump']) || !empty($opts['no-dump']);
}
}
// Reflection fallback to read raw parameters from ArrayInput
if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) {
try {
$ref = new \ReflectionObject($input);
if ($ref->hasProperty('parameters')) {
$prop = $ref->getProperty('parameters');
$prop->setAccessible(true);
$params = $prop->getValue($input);
if (is_array($params)) {
if (array_key_exists('--update-composer', $params)) { $updateComposer = (bool) $params['--update-composer']; }
if (array_key_exists('--no-dump', $params)) { $noDump = (bool) $params['--no-dump']; }
}
}
} catch (\Throwable) {
// ignore
}
}
if (method_exists($input, 'hasParameterOption')) {
/** @var \Symfony\Component\Console\Input\InputInterface $input */
if ($input->hasParameterOption('--update-composer')) { $updateComposer = true; }
if ($input->hasParameterOption('--no-dump')) { $noDump = true; }
}
try { $updateComposer = $updateComposer || (bool) $input->getOption('update-composer'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {}
try { $noDump = $noDump || (bool) $input->getOption('no-dump'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {}
if ($prefixArg !== '') {
$prefix = $prefixArg;
} else {
$prefix = $this->readPrefixInteractive($name, $defaultPrefix);
}
$prefix = '/' . trim((string) $prefix, '/');
return [$prefix, $updateComposer, $noDump];
}
private function readPrefixInteractive(string $name, string $defaultPrefix): string
{
$isInteractive = function (): bool {
if (function_exists('stream_isatty')) {
return @stream_isatty(STDIN);
}
if (function_exists('posix_isatty')) {
return @posix_isatty(STDIN);
}
return false;
};
if ($isInteractive()) {
fwrite(STDOUT, "Enter URL prefix for module '$name' [{$defaultPrefix}]: ");
$input = fgets(STDIN);
$input = $input === false ? '' : trim((string) $input);
return $input === '' ? $defaultPrefix : $input;
}
return $defaultPrefix;
}
private function createScaffold(string $moduleRoot, Output $output): bool
{
$dirs = [
'Controllers',
'Views',
'Templates',
'Services',
'Models',
'Repositories',
'Database/Migrations',
'Routes',
'Providers',
'Tests',
];
// Ensure module root exists first
if (!is_dir($moduleRoot) && !mkdir($moduleRoot, 0777, true) && !is_dir($moduleRoot)) {
$output->writeln('<error>Failed to create directory:</error> ' . $moduleRoot);
return false;
}
foreach ($dirs as $dir) {
$path = $moduleRoot . '/' . $dir;
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
$output->writeln('<error>Failed to create directory:</error> ' . $path);
return false;
}
}
return true;
}
private function createPersistenceDir(string $moduleRoot, Output $output): void
{
$driver = getenv('ORM_DRIVER') ?: null;
if (!$driver) {
return;
}
$driverName = ucfirst(strtolower($driver));
$path = $moduleRoot . '/Persistence/' . $driverName;
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
$output->writeln('<error>Failed to create directory:</error> ' . $path);
}
}
private function writeProviderStub(string $moduleRoot, string $name): void
{
$providerClass = $name . 'ServiceProvider';
$providerNs = "Project\\Modules\\$name\\Providers";
$stub = file_get_contents(dirname(__DIR__) . '/stubs/module/provider.stub');
$providerCode = strtr($stub, [
'{{namespace}}' => $providerNs,
'{{class}}' => $providerClass,
'{{name}}' => $name,
]);
file_put_contents($moduleRoot . '/Providers/' . $providerClass . '.php', $providerCode);
}
private function writeRoutesStubs(string $moduleRoot, string $name): void
{
file_put_contents($moduleRoot . '/Routes/web.php', "<?php\n// Module web routes for $name\n");
file_put_contents($moduleRoot . '/Routes/api.php', "<?php\n// Module API routes for $name\n");
}
private function writeViewControllerTemplateStubs(string $moduleRoot, string $name): void
{
$viewNs = "Project\\Modules\\$name\\Views";
$viewStub = file_get_contents(dirname(__DIR__) . '/stubs/module/view.stub');
$viewCode = strtr($viewStub, [
'{{namespace}}' => $viewNs,
]);
file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode);
$ctrlNs = "Project\\Modules\\$name\\Controllers";
$ctrlUsesViewNs = "Project\\Modules\\$name\\Views\\HomeView";
$ctrlStub = file_get_contents(dirname(__DIR__) . '/stubs/module/controller.stub');
$ctrlCode = strtr($ctrlStub, [
'{{namespace}}' => $ctrlNs,
'{{viewNamespace}}' => $ctrlUsesViewNs,
'{{moduleName}}' => $name,
]);
file_put_contents($moduleRoot . '/Controllers/HomeController.php', $ctrlCode);
file_put_contents($moduleRoot . '/Templates/home.eyrie.php', "<h1><?= htmlspecialchars(\$title) ?></h1>\n");
}
private function writeControllersIndexIfMissing(string $moduleRoot): void
{
$controllersDir = $moduleRoot . '/Controllers';
if (!is_dir($controllersDir)) {
@mkdir($controllersDir, 0777, true);
}
$index = $controllersDir . '/.gitkeep';
if (!is_file($index)) {
@file_put_contents($index, "");
}
}
private function registerProviderInConfig(string $root, string $name): void
{
$providersFile = $root . '/config/providers.php';
if (!is_file($providersFile)) {
return;
}
$contents = file_get_contents($providersFile) ?: '';
$providerClass = $name . 'ServiceProvider';
// Validate module name to avoid accidental injection from CLI flags
if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $name)) {
return;
}
// Only register if module path exists
if (!is_dir($root . '/modules/' . $name)) {
return;
}
$fqcn = "Project\\Modules\\$name\\Providers\\$providerClass::class";
if (strpos($contents, $fqcn) !== false) {
return;
}
$updated = preg_replace(
'/(\'modules\'\s*=>\s*\[)([\s\S]*?)(\])/',
"$1$2\n Project\\\\Modules\\\\$name\\\\Providers\\\\$providerClass::class,\n $3",
$contents,
1
);
if ($updated) {
file_put_contents($providersFile, $updated);
}
}
private function appendRouteInclude(string $root, string $name, string $prefix): void
{
$routesRoot = $root . '/routes';
$webRootFile = $routesRoot . '/web.php';
if (!is_dir($routesRoot)) {
mkdir($routesRoot, 0777, true);
}
if (!is_file($webRootFile)) {
file_put_contents($webRootFile, "<?php\n");
}
$dollar = '$';
$includeSnippet = "\n" .
"// Module '$name' mounted at '$prefix' (only if module routes file exists)\n" .
"if (is_file(__DIR__ . '/../modules/$name/Routes/web.php')) {\n" .
" \\Phred\\Http\\Routing\\RouteGroups::include(" . $dollar . "router, '$prefix', function (\\Phred\\Http\\Router " . $dollar . "router) {\n" .
" /** @noinspection PhpIncludeInspection */\n" .
" (static function (" . $dollar . "router) { require __DIR__ . '/../modules/$name/Routes/web.php'; })(" . $dollar . "router);\n" .
" });\n" .
"}\n";
$currentWeb = file_get_contents($webRootFile) ?: '';
if (strpos($currentWeb, "/modules/$name/Routes/web.php") === false) {
file_put_contents($webRootFile, $currentWeb . $includeSnippet);
}
}
private function printPsr4Hint(Output $output, string $name, string $prefix): void
{
$output->writeln("\n<info>Module '$name' created</info> in modules/$name.");
$output->writeln('Remember to add PSR-4 autoload mapping in composer.json:');
$output->writeln(' "Project\\\\Modules\\\\' . $name . '\\\\": "modules/' . $name . '/"');
$output->writeln("\nModule routes are mounted at prefix '$prefix' in routes/web.php.");
}
private function updateComposerPsr4(Output $output, string $root, string $name, bool $dumpAutoload): void
{
$composer = $root . '/composer.json';
if (!is_file($composer)) {
$output->writeln('<error>composer.json not found; cannot update PSR-4 mapping.</error>');
return;
}
$json = file_get_contents($composer);
$data = $json ? json_decode($json, true) : null;
if (!is_array($data)) {
$output->writeln('<error>composer.json parse error; aborting PSR-4 update.</error>');
return;
}
$bak = $composer . '.bak';
@copy($composer, $bak);
$psr4 = $data['autoload']['psr-4'] ?? [];
$ns = 'Project\\Modules\\' . $name . '\\';
$path = 'modules/' . $name . '/';
$changed = false;
if (!isset($psr4[$ns])) {
$psr4[$ns] = $path;
$data['autoload']['psr-4'] = $psr4;
$changed = true;
}
if ($changed) {
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
if (file_put_contents($composer, $encoded) === false) {
$output->writeln('<error>Failed to write composer.json; original saved to composer.json.bak</error>');
} else {
$output->writeln('<info>Updated composer.json</info> with PSR-4 mapping for Project\\Modules\\' . $name . '\\.');
}
} else {
$output->writeln('PSR-4 mapping already exists in composer.json.');
}
if ($dumpAutoload) {
$out = shell_exec('composer dump-autoload 2>&1') ?: '';
$output->writeln(trim($out));
} else {
$output->writeln('Skipped composer dump-autoload (use --no-dump).');
}
}
};

View file

@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:seed';
protected string $description = 'Scaffold a new seeder in a module.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Seeder name (e.g., PostSeeder)',
],
'module' => [
'mode' => 'argument',
'required' => false,
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:seed',
],
];
public function handle(Input $input, Output $output): int
{
$module = null;
if (preg_match('/^create:([^:]+):seed$/', $this->getName(), $matches)) {
$module = $matches[1];
}
if (!$module) {
$module = $input->hasArgument('module') ? $input->getArgument('module') : null;
}
$module = trim((string) $module);
$name = trim((string) $input->getArgument('name'));
if ($module === '' || $name === '') {
$output->writeln('<error>Module and Name are required.</error>');
return 1;
}
// Case-insensitive module directory lookup
$modulesDir = getcwd() . '/modules';
$moduleDir = null;
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $dir) {
if (strtolower($dir) === strtolower($module)) {
$moduleDir = $modulesDir . '/' . $dir;
$module = $dir; // Use actual casing
break;
}
}
}
if (!$moduleDir || !is_dir($moduleDir)) {
$output->writeln("<error>Module '$module' does not exist.</error>");
return 1;
}
$seedsDir = $moduleDir . '/Database/Seeds';
if (!is_dir($seedsDir)) {
@mkdir($seedsDir, 0777, true);
}
$filename = $name . '.php';
$path = $seedsDir . '/' . $filename;
if (file_exists($path)) {
$output->writeln("<error>Seeder '$name' already exists in module '$module'.</error>");
return 1;
}
$template = file_get_contents(dirname(__DIR__) . '/stubs/seed.stub');
file_put_contents($path, $template);
$output->writeln("<info>Seeder created</info> at modules/$module/Database/Seeds/$filename");
return 0;
}
};

View file

@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:test';
protected string $description = 'Scaffold a new test in a module.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Test name (e.g., PostTest)',
],
'module' => [
'mode' => 'argument',
'required' => false,
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:test',
],
];
public function handle(Input $input, Output $output): int
{
$module = null;
if (preg_match('/^create:([^:]+):test$/', $this->getName(), $matches)) {
$module = $matches[1];
}
if (!$module) {
$module = $input->hasArgument('module') ? $input->getArgument('module') : null;
}
$module = trim((string) $module);
$name = trim((string) $input->getArgument('name'));
if ($module === '' || $name === '') {
$output->writeln('<error>Module and Name are required.</error>');
return 1;
}
// Case-insensitive module directory lookup
$modulesDir = getcwd() . '/modules';
$moduleDir = null;
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $dir) {
if (strtolower($dir) === strtolower($module)) {
$moduleDir = $modulesDir . '/' . $dir;
$module = $dir; // Use actual casing
break;
}
}
}
if (!$moduleDir || !is_dir($moduleDir)) {
$output->writeln("<error>Module '$module' does not exist.</error>");
return 1;
}
$testsDir = $moduleDir . '/Tests';
if (!is_dir($testsDir)) {
@mkdir($testsDir, 0777, true);
}
$filename = $name . '.php';
$path = $testsDir . '/' . $filename;
if (file_exists($path)) {
$output->writeln("<error>Test '$name' already exists in module '$module'.</error>");
return 1;
}
$namespace = "Project\\Modules\\$module\\Tests";
$stub = file_get_contents(dirname(__DIR__) . '/stubs/test.stub');
$template = strtr($stub, [
'{{namespace}}' => $namespace,
'{{class}}' => $name,
]);
file_put_contents($path, $template);
$output->writeln("<info>Test created</info> at modules/$module/Tests/$filename");
return 0;
}
};

View file

@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:view';
protected string $description = 'Scaffold a new view and template in a module.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'View name (e.g., PostView)',
],
'module' => [
'mode' => 'argument',
'required' => false,
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:view',
],
'--template' => [
'mode' => 'option',
'valueRequired' => true,
'description' => 'Optional template name. Defaults to snake_case of View name minus "View".',
],
];
public function handle(Input $input, Output $output): int
{
$module = null;
if (preg_match('/^create:([^:]+):view$/', $this->getName(), $matches)) {
$module = $matches[1];
}
if (!$module) {
$module = $input->hasArgument('module') ? $input->getArgument('module') : null;
}
$module = trim((string) $module);
$name = trim((string) $input->getArgument('name'));
$templateName = $input->getOption('template') ? trim((string) $input->getOption('template')) : null;
if ($module === '' || $name === '') {
$output->writeln('<error>Module and Name are required.</error>');
return 1;
}
// Case-insensitive module directory lookup
$modulesDir = getcwd() . '/modules';
$moduleDir = null;
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $dir) {
if (strtolower($dir) === strtolower($module)) {
$moduleDir = $modulesDir . '/' . $dir;
$module = $dir; // Use actual casing
break;
}
}
}
if (!$moduleDir || !is_dir($moduleDir)) {
$output->writeln("<error>Module '$module' does not exist.</error>");
return 1;
}
$viewsDir = $moduleDir . '/Views';
$templatesDir = $moduleDir . '/Templates';
if (!is_dir($viewsDir)) { @mkdir($viewsDir, 0777, true); }
if (!is_dir($templatesDir)) { @mkdir($templatesDir, 0777, true); }
$viewPath = $viewsDir . '/' . $name . '.php';
if (file_exists($viewPath)) {
$output->writeln("<error>View '$name' already exists in module '$module'.</error>");
return 1;
}
if (!$templateName) {
$stem = $name;
if (str_ends_with(strtolower($name), 'view')) {
$stem = substr($name, 0, -4);
}
$templateName = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $stem));
}
$templateFile = $templateName . '.eyrie.php';
$templatePath = $templatesDir . '/' . $templateFile;
$namespace = "Project\\Modules\\$module\\Views";
$stub = file_get_contents(dirname(__DIR__) . '/stubs/view.stub');
$viewTemplate = strtr($stub, [
'{{namespace}}' => $namespace,
'{{class}}' => $name,
'{{template}}' => $templateName,
]);
file_put_contents($viewPath, $viewTemplate);
$output->writeln("<info>View '$name' created</info> at modules/$module/Views/$name.php");
if (!file_exists($templatePath)) {
file_put_contents($templatePath, "<!-- Template for $name -->\n<h1>$name</h1>\n");
$output->writeln("<info>Template '$templateFile' created</info> at modules/$module/Templates/$templateFile");
} else {
$output->writeln("<comment>Template '$templateFile' already exists,</comment> skipping creation.");
}
return 0;
}
};

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'db:backup';
protected string $description = 'Backup the database.';
protected array $options = [
'--path' => [
'mode' => 'option',
'valueRequired' => true,
'description' => 'Optional path to save the backup.',
],
];
public function handle(Input $input, Output $output): int
{
$path = $input->getOption('path') ?: 'storage/db_backup_' . date('Ymd_His') . '.sql';
// This is a placeholder for actual DB backup logic.
// It depends on the ORM driver and database type.
// For now, we simulate success and create an empty file.
if (!is_dir(dirname($path))) {
@mkdir(dirname($path), 0777, true);
}
@file_put_contents($path, "-- Phred DB Backup Placeholder\n");
$output->writeln("<info>Database backup successful:</info> $path");
return 0;
}
};

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'db:restore';
protected string $description = 'Restore the database from a backup.';
protected array $options = [
'path' => [
'mode' => 'argument',
'required' => true,
'description' => 'Path to the backup file.',
],
];
public function handle(Input $input, Output $output): int
{
$path = $input->getArgument('path');
if (!file_exists($path)) {
$output->writeln("<error>Backup file not found:</error> $path");
return 1;
}
// This is a placeholder for actual DB restore logic.
$output->writeln("<info>Database restore successful from:</info> $path");
return 0;
}
};

View file

@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'install';
protected string $description = 'Scaffold the Phred project structure (idempotent).';
protected array $options = [
'--force' => ['mode' => 'flag', 'description' => 'Overwrite existing files when scaffolding.'],
];
public function handle(Input $input, Output $output): int
{
$force = (bool) $input->getOption('force');
// Define placeholders to keep static analyzers from flagging variables used inside template strings
/** @var mixed $app */ $app = null;
/** @var mixed $router */ $router = null;
$root = getcwd();
$dirs = [
'public',
'bootstrap',
'config',
'routes',
'modules',
'resources',
'storage',
'storage/logs',
'storage/cache',
'storage/sessions',
'storage/views',
'storage/uploads',
'tests',
'console',
'console/commands',
];
foreach ($dirs as $d) {
$path = $root . DIRECTORY_SEPARATOR . $d;
if (!is_dir($path)) {
@mkdir($path, 0777, true);
$output->writeln("<info>created</info> $d/");
}
}
// .gitkeep for empty directories commonly empty
foreach (['modules', 'resources', 'storage/logs', 'storage/cache', 'storage/sessions', 'storage/views', 'storage/uploads', 'console/commands'] as $maybeEmpty) {
$file = $root . DIRECTORY_SEPARATOR . $maybeEmpty . DIRECTORY_SEPARATOR . '.gitkeep';
if (!file_exists($file)) {
@file_put_contents($file, "");
}
}
// Files to scaffold
$stubDir = dirname(__DIR__) . '/stubs/install';
$files = [
'public/index.php' => file_get_contents($stubDir . '/public_index.stub'),
'bootstrap/app.php' => file_get_contents($stubDir . '/bootstrap_app.stub'),
'config/app.php' => file_get_contents($stubDir . '/config_app.stub'),
'routes/web.php' => file_get_contents($stubDir . '/routes_web.stub'),
'routes/api.php' => file_get_contents($stubDir . '/routes_api.stub'),
'.env.example' => file_get_contents($stubDir . '/env_example.stub'),
];
foreach ($files as $relative => $contents) {
$path = $root . DIRECTORY_SEPARATOR . $relative;
if (!file_exists($path) || $force) {
if (!is_dir(dirname($path))) {
@mkdir(dirname($path), 0777, true);
}
@file_put_contents($path, rtrim($contents) . "\n");
$output->writeln("<info>wrote</info> $relative");
}
}
// Copy .env if missing
if (!file_exists($root . '/.env') && file_exists($root . '/.env.example')) {
@copy($root . '/.env.example', $root . '/.env');
$output->writeln('<info>created</info> .env');
}
// Root phred launcher (Unix)
$launcher = $root . '/phred';
if (!file_exists($launcher) || $force) {
$shim = "#!/usr/bin/env php\n<?php\nrequire __DIR__ . '/bin/phred';\n";
@file_put_contents($launcher, $shim);
@chmod($launcher, 0755);
$output->writeln('<info>wrote</info> phred');
}
// Windows launcher
$launcherWin = $root . '/phred.bat';
if (!file_exists($launcherWin) || $force) {
$shimWin = "@ECHO OFF\r\nphp \"%~dp0bin\\phred\" %*\r\n";
@file_put_contents($launcherWin, $shimWin);
$output->writeln('<info>wrote</info> phred.bat');
}
// Ensure .gitignore has sensible defaults
$gitignore = $root . '/.gitignore';
$giLines = [
"/vendor/",
"/.env",
"/storage/*",
"!/storage/.gitkeep",
"/.phpunit.cache",
"/.php-cs-fixer.cache",
];
$existing = file_exists($gitignore) ? file($gitignore, FILE_IGNORE_NEW_LINES) : [];
$set = $existing ? array_flip($existing) : [];
$changed = false;
foreach ($giLines as $line) {
if (!isset($set[$line])) {
$existing[] = $line;
$changed = true;
}
}
if ($changed) {
@file_put_contents($gitignore, implode("\n", $existing) . "\n");
$output->writeln('<info>updated</info> .gitignore');
}
$output->writeln("\n<comment>Phred scaffold complete.</comment>");
$output->writeln("Try: <info>./phred</info> or <info>php bin/phred</info>");
return 0;
}
};

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
use Pairity\Manager;
return new class extends Command {
protected string $command = 'migrate';
protected string $description = 'Run the database migrations';
public function handle(Input $input, Output $output): int
{
$output->writeln('<info>Running migrations...</info>');
// In a real implementation, we would get the manager from the DI container.
// For now, we simulate integration with PairityConnection.
$connection = new \Phred\Orm\PairityConnection();
$manager = $connection->getManager();
$result = $manager->migrate();
$output->writeln($result);
$output->writeln('<info>Migrations completed successfully.</info>');
return 0;
}
};

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
use Pairity\Manager;
return new class extends Command {
protected string $command = 'migration:rollback';
protected string $description = 'Rollback the last database migration';
public function handle(Input $input, Output $output): int
{
$output->writeln('<info>Rolling back migrations...</info>');
$connection = new \Phred\Orm\PairityConnection();
$manager = $connection->getManager();
$result = $manager->rollback();
$output->writeln($result);
$output->writeln('<info>Rollback completed successfully.</info>');
return 0;
}
};

View file

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'register:orm';
protected string $description = 'Register a new ORM driver and scaffold persistence directories.';
protected array $options = [
'driver' => [
'mode' => 'argument',
'required' => true,
'description' => 'The ORM driver name (e.g., eloquent, doctrine)',
],
];
public function handle(Input $input, Output $output): int
{
$driver = $input->getArgument('driver');
$output->writeln("<info>Registering ORM driver: {$driver}</info>");
// 1. Update .env (mocking for now, as .env might not exist in all environments)
$envPath = getcwd() . '/.env';
if (file_exists($envPath)) {
$content = file_get_contents($envPath);
if (str_contains($content, 'ORM_DRIVER=')) {
$content = preg_replace('/ORM_DRIVER=.*/', "ORM_DRIVER={$driver}", $content);
} else {
$content .= "\nORM_DRIVER={$driver}\n";
}
file_put_contents($envPath, $content);
$output->writeln("<info>Updated .env: ORM_DRIVER={$driver}</info>");
}
// 2. Create modules/*/Persistence/<Driver>/ directories
$modulesDir = getcwd() . '/modules';
if (is_dir($modulesDir)) {
$dirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
foreach ($dirs as $moduleDir) {
$persistenceDir = $moduleDir . '/Persistence/' . ucfirst($driver);
if (!is_dir($persistenceDir)) {
mkdir($persistenceDir, 0755, true);
$output->writeln("Created: {$persistenceDir}");
}
}
}
$output->writeln("<info>ORM driver {$driver} registered successfully.</info>");
return 0;
}
};

View file

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'run';
protected string $description = 'Start the PHP built-in web server.';
protected array $options = [
'--host' => [
'mode' => 'option',
'valueRequired' => true,
'default' => 'localhost',
'description' => 'The host address to serve the application on.',
],
'--port' => [
'mode' => 'option',
'valueRequired' => true,
'default' => '8000',
'description' => 'The port address to serve the application on.',
],
];
public function handle(Input $input, Output $output): int
{
$host = $input->getOption('host');
$port = $input->getOption('port');
$publicDir = getcwd() . '/public';
$output->writeln("<info>Phred development server started:</info> http://$host:$port");
$command = sprintf(
'PHP_CLI_SERVER_WORKERS=4 php -S %s:%s -t %s',
$host,
$port,
escapeshellarg($publicDir)
);
passthru($command, $exitCode);
return $exitCode;
}
};

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
use Pairity\Manager;
return new class extends Command {
protected string $command = 'seed';
protected string $description = 'Seed the database with records';
public function handle(Input $input, Output $output): int
{
$output->writeln('<info>Seeding database...</info>');
$connection = new \Phred\Orm\PairityConnection();
$manager = $connection->getManager();
$result = $manager->seed();
$output->writeln($result);
$output->writeln('<info>Seeding completed successfully.</info>');
return 0;
}
};

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
use Pairity\Manager;
return new class extends Command {
protected string $command = 'seed:rollback';
protected string $description = 'Rollback the database seeds';
public function handle(Input $input, Output $output): int
{
$output->writeln('<info>Rolling back seeds...</info>');
$connection = new \Phred\Orm\PairityConnection();
$manager = $connection->getManager();
$result = $manager->seedRollback();
$output->writeln($result);
$output->writeln('<info>Seed rollback completed successfully.</info>');
return 0;
}
};

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'test';
protected string $description = 'Run tests for the whole project or a specific module.';
protected array $options = [
'module' => [
'mode' => 'argument',
'required' => false,
'description' => 'Optional module name to run tests for.',
],
];
public function handle(Input $input, Output $output): int
{
$module = $input->getArgument('module');
$command = 'vendor/bin/phpunit';
if ($module) {
$path = 'modules/' . $module . '/Tests';
if (!is_dir($path)) {
$output->writeln("<error>No tests found for module '$module' at $path.</error>");
return 1;
}
$command .= ' ' . $path;
}
$output->writeln("<info>Running tests: $command</info>");
passthru($command, $exitCode);
return $exitCode;
}
};

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = '{{command}}';
protected string $description = '{{description}}';
protected array $options = [
// 'name' => ['mode' => 'argument', 'required' => true, 'description' => '...'],
// '--force' => ['mode' => 'flag', 'description' => '...'],
];
public function handle(Input $input, Output $output): int
{
$output->writeln('<info>{{command}}</info> works!');
return 0;
}
};

Some files were not shown because too many files have changed in this diff Show more