chore: reset Framework project to Idea phase

This commit is contained in:
Funky Waddle 2026-02-22 02:20:30 -06:00
parent 54303282d7
commit e4d041afa9
173 changed files with 139 additions and 8780 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,29 +0,0 @@
APP_NAME=Phred App
APP_ENV=local
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost
API_FORMAT=rest
API_PROBLEM_DETAILS=true
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
MODULE_NAMESPACE=Modules
COMPRESSION_ENABLED=false
COMPRESSION_LEVEL_GZIP=-1
COMPRESSION_LEVEL_BROTLI=4
CORS_ALLOWED_ORIGINS=*
CORS_ALLOWED_HEADERS="Content-Type, Authorization"
CORS_ALLOWED_METHODS="GET, POST, PUT, PATCH, DELETE, OPTIONS"

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

140
.gitignore vendored
View file

@ -1,140 +0,0 @@
# ---> Composer
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
# ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# 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,19 +0,0 @@
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/modules')
->in(__DIR__ . '/tests')
->in(__DIR__ . '/config');
$config = new PhpCsFixer\Config();
return $config->setRules([
'@PSR12' => true,
'strict_param' => true,
'array_syntax' => ['syntax' => 'short'],
'no_unused_imports' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'single_quote' => true,
'trailing_comma_in_multiline' => ['elements' => ['arrays']],
])
->setFinder($finder);

View file

@ -1,24 +0,0 @@
# Contributing to Phred
Thank you for your interest in Phred! We welcome contributions of all kinds.
## RFC Process
For major changes, please open an issue first to discuss what you would like to change.
1. Create a "Request for Comments" (RFC) issue.
2. Wait for feedback from the core maintainers.
3. Once approved, submit a Pull Request.
## Pull Request Process
1. Fork the repository and create your branch from `main`.
2. If you've added code that should be tested, add tests.
3. Ensure the test suite passes (`composer test`).
4. Ensure static analysis passes (`composer analyze`).
5. Ensure code style matches PSR-12 (`composer fix`).
6. Update the documentation if necessary.
## Code of Conduct
Please be respectful and professional in all interactions.

View file

@ -1,22 +0,0 @@
FROM php:8.2-fpm
RUN apt-get update && apt-get install -y \
libpq-dev \
libzip-dev \
zip \
unzip \
git \
&& docker-php-ext-install pdo_mysql pdo_pgsql zip
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
COPY . .
RUN composer install --no-dev --optimize-autoloader
RUN chown -R www-data:www-data storage bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]

View file

@ -1,9 +0,0 @@
MIT License
Copyright (c) 2025 funkywaddle
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

136
NOTES.md Normal file
View file

@ -0,0 +1,136 @@
# Phred Framework: Project Notes & Brainstorming
This document serves as a mind-map and repository for ideas, architectural decisions, and future considerations for the Phred Framework. It is a living document that captures the "why" behind the "what" defined in the specifications and milestones.
## Core Vision & Philosophy
- **Batteries-Included but Swappable**: Provide sensible defaults (REST, JSON:API, Eyrie, Pairity) while ensuring every core component can be swapped via Service Providers.
- **Standards First**: Strict adherence to PSRs (PSR-7, PSR-11, PSR-14, PSR-15, PSR-18).
- **Modular by Nature**: Django-style "Apps" (Modules) where features are encapsulated.
- **Developer Happiness (DX)**: Zero-config where possible, robust scaffolding, and clear documentation.
## Architectural Brainstorming
### 1. The HTTP Pipeline (PSR-15)
- Use `Relay` for the middleware stack.
- **Middleware Layers**:
- Global: CORS, Security Headers, Error Handling (Problem Details).
- Route-specific: Auth (JWT), Validation, Rate Limiting.
- Negotiation: `ContentNegotiationMiddleware` to handle REST vs JSON:API vs HTML.
### 2. Pluggability & Service Providers
- **Contracts Package**: Define all core interfaces in `Phred\Support\Contracts`.
- **Driver Selection**: Use `.env` keys like `ORM_DRIVER`, `TEMPLATE_DRIVER`, `CACHE_DRIVER`.
- **Lifecycle**: Providers should have `register()` (binding to container) and `boot()` (executing logic like route registration).
### 3. MVC & View Layer
- **Invokable Controllers**: "One action, one class" to prevent controller bloat.
- **View Objects**: A dedicated layer for data transformation/preparation before the template. This keeps controllers focused on flow and templates focused on markup.
- **Response Factories**: Abstract the creation of REST vs JSON:API responses.
### 4. Modular Architecture (Django-style)
- All user code lives in `modules/`.
- **Contract-First Persistence**: Modules should define pure domain models (POPOs) and repository interfaces. The framework uses a "Bridge" architecture to map these to a specific ORM.
- **Scaffolding**: CLI should be able to generate an entire module structure in one command.
### 5. Persistence Bridge Strategy (ORM-Agnostic)
To achieve true decoupling, Phred adopts a "Persistence Bridge" pattern, mirroring the TaskerBridges architecture. This removes the need for driver-specific directories (like `Persistence/Pairity/`) within modules and moves implementation details to the framework's infrastructure layer.
#### Implementation Concept
1. **Flattened Directory Structure**:
- `modules/<Module>/Models/`: Contains the data objects/entities (POPOs).
- `modules/<Module>/Repositories/`: Contains the repository interfaces.
2. **The Bridge as a Translator**:
- The framework boots an active **ORM Bridge** (e.g., `PairityBridge`, `EloquentBridge`) based on `.env`.
- The Bridge is responsible for taking a module's model and explaining it to its respective persistence engine using PHP 8 Attributes (metadata).
3. **Automated Discovery & Wiring**:
- Similar to Tasker's command discovery, Phred scans the `Models/` directory to register entities.
- Phred auto-binds repository implementations to their corresponding interfaces in the PSR-11 container.
4. **Environment-Driven**: Connection details and driver selection are handled exclusively via environment variables, keeping module code pure.
5. **Migration & Schema Management**:
- **Auto-Discovery**: Bridges scan `Models/` to detect schema changes.
- **Versioned Snapshots**: The framework can cache a "last-known" state of the models to generate diff-based migrations.
- **Tooling**: CLI commands can generate migration files by comparing current POPO attributes against the database or a cached snapshot.
#### Example Domain Model (Attribute-Driven)
```php
namespace App\Modules\User\Models;
use Phred\Persistence\Attributes\Entity;
use Phred\Persistence\Attributes\Column;
#[Entity(table: 'users')]
class User
{
#[Column(primary: true, autoIncrement: true)]
public int $id;
#[Column(unique: true)]
public string $email;
#[Column(nullable: true)]
public ?string $name;
#[Column(name: 'created_at')]
public \DateTimeImmutable $createdAt;
}
```
#### Benefits
- **Zero-Config Persistence**: Developers only write the Model and the Interface; Phred handles the connection and injection.
- **Multi-ORM Support**: The same model can work across different ORMs by switching the Bridge.
- **Simplified DX**: No deeply nested folders for every module; logic is localized and clean.
### 6. Command Discovery Strategy (Tasker Integration)
To provide a "zero-configuration" experience for developers, the framework should implement an automated directory-based command loader that bridges the application's `src/Commands` directory with Tasker.
#### Proposed Implementation Flow
1. **Directory Scan**: Use `RecursiveDirectoryIterator` to find all PHP files in `src/Commands`.
2. **Class Resolution**: Map file paths to fully qualified class names following PSR-4 conventions.
3. **Container-First Loading**:
- Check if the class is already registered in the PSR-11 container.
- If found, retrieve the instance from the container (ensuring dependency injection).
- If not found, Tasker's `Runner::register($className)` will instantiate it directly.
4. **Registration**: Pass the resolved class name or instance to `$taskerRunner->register()`.
5. **Migration & Schema Management**:
- **Auto-Discovery**: Bridges scan `Models/` to detect schema changes.
- **Versioned Snapshots**: The framework can cache a "last-known" state of the models to generate diff-based migrations.
- **Tooling**: CLI commands can generate migration files by comparing current POPO attributes against the database or a cached snapshot.
#### Benefits
- **Zero Config**: Developers just drop a class into the directory.
- **DI Support**: Automatically respects container-managed services.
- **Type Safety**: Tasker validates that the class implements `CommandInterface` or uses the `HasAttributes` trait.
#### Example Discovery Logic
```php
$commandPath = $appRoot . '/src/Commands';
$namespace = 'App\Commands\';
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($commandPath));
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$className = $namespace . str_replace(
['/', '.php'],
['\', ''],
substr($file->getPathname(), strlen($commandPath) + 1)
);
$taskerRunner->register($className);
}
```
### 7. Security & Auth
- JWT by default for APIs.
- CSRF protection for traditional web routes.
- **Feature Flags**: Native integration with `FlagPole`.
### 8. Documentation & Discovery
- Documentation site built *with* Phred.
- **Dynamic Help**: A CLI command that opens the online docs for a specific framework command.
- **OpenAPI**: Auto-generation from annotations.

149
README.md
View file

@ -1,149 +0,0 @@
# 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 Shop
```
This will create the module structure under `modules/Shop`, 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": {
"Modules\\\\Shop\\\\": "modules/Shop/"
}
}
}
```
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`.
## Configuration
Phred uses a `.env` file for configuration. Key settings include:
* `API_FORMAT`: The default API format (`rest` or `jsonapi`).
* `MODULE_NAMESPACE`: The base namespace for your modules (default: `Modules`).
* `APP_DEBUG`: Enable debug mode (`true` or `false`).
* `COMPRESSION_ENABLED`: Enable response compression (Gzip/Brotli).
## 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 module:list` — List all discovered and registered modules.
* `php phred module:sync-ns` — Sync `composer.json` PSR-4 with `MODULE_NAMESPACE`.
* `php phred list` — List all available commands.
## Routing
Routes are defined in `routes/web.php`, `routes/api.php`, or within your module's `Routes/` directory. Phred uses `nikic/fast-route` for high-performance routing.
### Basic Routes
You can define routes using the `$router` instance:
```php
// routes/web.php
$router->get('/welcome', WelcomeController::class);
$router->post('/submit', SubmitFormController::class);
```
### Route Groups
Groups allow you to share prefixes and middleware:
```php
$router->group(['prefix' => '/api', 'middleware' => 'api'], function ($router) {
$router->get('/users', ListUsersController::class);
$router->get('/users/{id}', ShowUserController::class);
});
```
### Module Auto-Mounting
Phred automatically mounts module routes based on folder name:
- `modules/Shop/Routes/web.php` → mounted at `/shop`
- `modules/Shop/Routes/api.php` → mounted at `/api/shop` (with `api` middleware)
### Route Listing & Caching
View all registered routes:
```bash
php phred route:list
```
In production, cache your routes for maximum performance:
```bash
php phred route:cache
php phred route:clear # To clear cache
```
## 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.

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:shop: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,12 +0,0 @@
namespace: Tests
support_namespace: Support
paths:
tests: tests
output: tests/_output
data: tests/Support/Data
support: tests/Support
envs: tests/_envs
actor_suffix: Tester
extensions:
enabled:
- Codeception\Extension\RunFailed

View file

@ -6,9 +6,9 @@
"php": "^8.2",
"crell/api-problem": "^3.7",
"filp/whoops": "^2.15",
"getphred/eyrie": "dev-main",
"getphred/flagpole": "dev-main",
"getphred/pairity": "dev-main",
"getphred/eyrie": "dev-master",
"getphred/flagpole": "dev-master",
"getphred/pairity": "dev-master",
"laravel/serializable-closure": "^1.3",
"lcobucci/jwt": "^5.2",
"league/flysystem": "^3.24",

View file

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

View file

@ -1,19 +0,0 @@
server {
listen 80;
server_name localhost;
root /var/www/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

View file

@ -1,76 +0,0 @@
parameters:
ignoreErrors:
-
message: "#^PHPDoc type for property class@anonymous/Console/Command\\.php\\:34\\:\\:\\$name with type string\\|null is not subtype of native type string\\.$#"
count: 1
path: src/Console/Command.php
-
message: "#^Property class@anonymous/Console/Command\\.php\\:34\\:\\:\\$name is never read, only written\\.$#"
count: 1
path: src/Console/Command.php
-
message: "#^Call to an undefined method Flagpole\\\\FeatureManager\\:\\:enabled\\(\\)\\.$#"
count: 1
path: src/Flags/FlagpoleClient.php
-
message: "#^Class FlagPole\\\\Context referenced with incorrect case\\: Flagpole\\\\Context\\.$#"
count: 1
path: src/Flags/FlagpoleClient.php
-
message: "#^Class FlagPole\\\\Repository\\\\InMemoryFlagRepository referenced with incorrect case\\: Flagpole\\\\Repository\\\\InMemoryFlagRepository\\.$#"
count: 1
path: src/Flags/FlagpoleClient.php
-
message: "#^Class Flagpole\\\\FeatureManager does not have a constructor and must be instantiated without any parameters\\.$#"
count: 1
path: src/Flags/FlagpoleClient.php
-
message: "#^Property Phred\\\\Http\\\\Middleware\\\\ContentNegotiationMiddleware\\:\\:\\$negotiator is never read, only written\\.$#"
count: 1
path: src/Http/Middleware/ContentNegotiationMiddleware.php
-
message: "#^Method Phred\\\\Http\\\\Middleware\\\\ProblemDetailsMiddleware\\:\\:deriveStatus\\(\\) is unused\\.$#"
count: 1
path: src/Http/Middleware/ProblemDetailsMiddleware.php
-
message: "#^Method Whoops\\\\Handler\\\\PrettyPageHandler\\:\\:handle\\(\\) invoked with 1 parameter, 0 required\\.$#"
count: 1
path: src/Http/Middleware/ProblemDetailsMiddleware.php
-
message: "#^Function Sentry\\\\init not found\\.$#"
count: 1
path: src/Providers/Core/LoggingServiceProvider.php
-
message: "#^Parameter \\#1 \\$handler of method Monolog\\\\Logger\\:\\:pushHandler\\(\\) expects Monolog\\\\Handler\\\\HandlerInterface, Sentry\\\\Monolog\\\\Handler given\\.$#"
count: 1
path: src/Providers/Core/LoggingServiceProvider.php
-
message: "#^Class Phred\\\\Orm\\\\EloquentConnection not found\\.$#"
count: 1
path: src/Providers/Core/OrmServiceProvider.php
-
message: "#^Method Phred\\\\Providers\\\\Core\\\\StorageServiceProvider\\:\\:createS3Adapter\\(\\) has invalid return type League\\\\Flysystem\\\\AwsS3V3\\\\AwsS3V3Adapter\\.$#"
count: 1
path: src/Providers/Core/StorageServiceProvider.php
-
message: "#^Strict comparison using \\=\\=\\= between string and null will always evaluate to false\\.$#"
count: 1
path: src/Support/Config.php
-
message: "#^Deprecated in PHP 8\\.4\\: Parameter \\#1 \\$host \\(string\\) is implicitly nullable via default value null\\.$#"
count: 1
path: src/Support/Http/CircuitBreakerMiddleware.php

View file

@ -1,16 +0,0 @@
includes:
- phpstan-baseline.neon
parameters:
level: 5
paths:
- src
- modules
- config
- bootstrap
ignoreErrors:
- identifier: missingType.iterableValue
- identifier: missingType.generics
reportUnmatchedIgnoredErrors: false
excludePaths:
- vendor

View file

@ -1,18 +0,0 @@
parameters:
level: 5
paths:
- src
ignoreErrors:
-
identifier: missingType.iterableValue
-
identifier: missingType.generics
reportUnmatchedIgnoredErrors: false
checkUninitializedProperties: true
inferPrivatePropertyTypeFromConstructor: true
services:
-
class: Phred\Support\PhpStan\Rules\InvokableControllerRule
tags:
- phpstan.rules.rule

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,104 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Cache;
use Psr\SimpleCache\CacheInterface;
final class FileCache implements CacheInterface
{
private string $directory;
public function __construct(string $directory)
{
$this->directory = rtrim($directory, DIRECTORY_SEPARATOR);
if (!is_dir($this->directory)) {
mkdir($this->directory, 0777, true);
}
}
public function get(string $key, mixed $default = null): mixed
{
$file = $this->getFile($key);
if (!is_file($file)) {
return $default;
}
$data = unserialize(file_get_contents($file));
if ($data['expires'] !== null && $data['expires'] < time()) {
$this->delete($key);
return $default;
}
return $data['value'];
}
public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
{
$expires = null;
if ($ttl instanceof \DateInterval) {
$expires = time() + (int) (new \DateTime())->add($ttl)->format('U') - time();
} elseif (is_int($ttl)) {
$expires = time() + $ttl;
}
$data = [
'value' => $value,
'expires' => $expires,
];
return file_put_contents($this->getFile($key), serialize($data)) !== false;
}
public function delete(string $key): bool
{
$file = $this->getFile($key);
if (is_file($file)) {
return unlink($file);
}
return true;
}
public function clear(): bool
{
foreach (glob($this->directory . DIRECTORY_SEPARATOR . '*') ?: [] as $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) !== null;
}
private function getFile(string $key): string
{
return $this->directory . DIRECTORY_SEPARATOR . sha1($key) . '.cache';
}
}

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(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,45 +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
* @param array<string,string|string[]> $headers
*/
public function fromArray(array $payload, int $status = 200, array $headers = []): 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,62 +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;
use OpenApi\Attributes as OA;
final class HealthController
{
use \Phred\Http\Support\ConditionalRequestTrait;
public function __construct(private ApiResponseFactoryInterface $factory) {}
#[OA\Get(
path: "/_phred/health",
summary: "Check framework health",
tags: ["System"],
responses: [
new OA\Response(
response: 200,
description: "System is healthy",
content: new OA\JsonContent(
properties: [
new OA\Property(property: "ok", type: "boolean"),
new OA\Property(property: "framework", type: "string")
],
type: "object"
)
)
]
)]
public function __invoke(Request $request): ResponseInterface
{
$type = $request->getQueryParams()['type'] ?? 'liveness';
$data = [
'ok' => true,
'framework' => 'Phred',
'type' => $type,
'timestamp' => time(),
];
// Readiness check could include DB connection check, etc.
if ($type === 'readiness') {
// Placeholder for actual checks
$data['checks'] = [
'database' => 'connected',
'storage' => 'writable',
];
}
$etag = $this->generateEtag($data);
if ($this->isFresh($request, $etag)) {
return (new \Nyholm\Psr7\Factory\Psr17Factory())->createResponse(304);
}
return $this->factory->fromArray($data, 200, ['ETag' => $etag]);
}
}

View file

@ -1,31 +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;
use Nyholm\Psr7\Factory\Psr17Factory;
final class OpenApiJsonController
{
public function __construct(
private ApiResponseFactoryInterface $factory
) {}
public function __invoke(Request $request): ResponseInterface
{
$path = getcwd() . '/public/openapi.json';
if (!is_file($path)) {
error_log("OpenAPI file not found at: " . $path);
return $this->factory->error(404, 'OpenAPI specification not found', 'Run `php bin/phred generate:openapi` first.');
}
$content = file_get_contents($path);
return (new Psr17Factory())
->createResponse(200)
->withHeader('Content-Type', 'application/json')
->withBody((new Psr17Factory())->createStream($content));
}
}

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Controllers;
use Phred\Support\Contracts\ConfigInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Nyholm\Psr7\Factory\Psr17Factory;
final class OpenApiUiController
{
public function __construct(
private ConfigInterface $config
) {}
public function __invoke(Request $request): ResponseInterface
{
$jsonUrl = $this->config->get('APP_URL', 'http://localhost:8000') . '/_phred/openapi.json';
$html = <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>Phred API Documentation</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
<redoc spec-url='{$jsonUrl}'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>
HTML;
return (new Psr17Factory())
->createResponse(200)
->withHeader('Content-Type', 'text/html')
->withBody((new Psr17Factory())->createStream($html));
}
}

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,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\JsonApi;
interface SchemaProviderInterface
{
public function getResourceType(): string;
public function getId(mixed $resource): string;
public function getAttributes(mixed $resource): array;
public function getRelationships(mixed $resource): array;
public function getLinks(mixed $resource): array;
}

View file

@ -1,273 +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
{
// SYNC: Initialize RequestContext with the current request so that
// any service resolving it (like DelegatingApiResponseFactory) is correct.
\Phred\Http\RequestContext::set($request);
$psr17 = $this->container->get(\Nyholm\Psr7\Factory\Psr17Factory::class);
$config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class);
// CORS
$corsSettings = new \Neomerx\Cors\Strategies\Settings();
$scheme = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_SCHEME) ?: 'http';
$host = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_HOST) ?: 'localhost';
$port = (int)parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_PORT) ?: 80;
$corsSettings->init($scheme, $host, $port);
$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();
// Initialize RequestContext with the original request
\Phred\Http\RequestContext::set($request);
$debug = filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN);
$middleware = [
new Middleware\TrustedProxiesMiddleware($config),
new Middleware\CompressionMiddleware(),
];
if ($debug) {
$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 class implements \Psr\Http\Server\MiddlewareInterface {
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
{
\Phred\Http\RequestContext::set($request);
return $handler->handle($request);
}
},
new Middleware\ProblemDetailsMiddleware(
$debug,
null,
null,
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
),
// Ensure RequestContext is initialized before anyone needs it
new class implements \Psr\Http\Server\MiddlewareInterface {
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
{
\Phred\Http\RequestContext::set($request);
return $handler->handle($request);
}
},
new Middleware\UrlExtensionNegotiationMiddleware(),
new Middleware\ContentNegotiationMiddleware($config),
new class implements \Psr\Http\Server\MiddlewareInterface {
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
{
\Phred\Http\RequestContext::set($request);
return $handler->handle($request);
}
},
new \Phred\Http\Middleware\JsonApi\JsonApiQueryMiddleware(),
new \Phred\Http\Middleware\Cache\ResponseCacheMiddleware(
$this->container->get(\Psr\SimpleCache\CacheInterface::class),
(int) $config->get('CACHE_TTL', 3600)
),
new class implements \Psr\Http\Server\MiddlewareInterface {
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
{
// Refresh RequestContext from the actual request object in the pipeline
\Phred\Http\RequestContext::set($request);
return $handler->handle($request);
}
},
new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
new Middleware\MiddlewareGroupMiddleware($config, $this->container),
new Middleware\DispatchMiddleware($this->container, $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),
\Nyholm\Psr7\Factory\Psr17Factory::class => \DI\autowire(\Nyholm\Psr7\Factory\Psr17Factory::class),
\Psr\Http\Message\ServerRequestInterface::class => function () {
return \Phred\Http\RequestContext::get();
},
]);
$container = $builder->build();
// Boot providers after container is available
// Reset provider-registered routes to avoid duplicates across multiple kernel instantiations (e.g., tests)
\Phred\Http\Routing\RouteRegistry::clear();
$providers->bootAll($container);
return $container;
}
private function buildDispatcher(): Dispatcher
{
$cachePath = dirname(__DIR__, 2) . '/storage/cache/routes.php';
if (file_exists($cachePath)) {
/** @noinspection PhpIncludeInspection */
$data = require $cachePath;
$data = $this->unserializeRoutes($data);
return new \FastRoute\Dispatcher\GroupCountBased($data);
}
return simpleDispatcher($this->getRouteCollector());
}
private function unserializeRoutes(array $data): array
{
array_walk_recursive($data, function (&$item) {
if (is_string($item) && str_contains($item, 'SerializableClosure')) {
try {
$unserialized = unserialize($item);
if ($unserialized instanceof \Laravel\SerializableClosure\SerializableClosure) {
$item = $unserialized->getClosure();
}
} catch (\Throwable) {
// Not a serializable closure or failed to unserialize
}
}
});
return $data;
}
/**
* @return callable(RouteCollector):void
*/
public function getRouteCollector(): callable
{
$routesPath = dirname(__DIR__, 2) . '/routes';
$registry = \Phred\Http\Routing\RouteRegistry::class;
return function (RouteCollector $r) use ($routesPath, $registry): 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)) {
$middleware = $file === 'api.php' ? ['api'] : [];
$router->group(['prefix' => '', 'middleware' => $middleware], static function (Router $router) use ($path): void {
/** @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, array $middleware = []) use ($modRoutes, $router, $registry): void {
$file = $modRoutes . '/' . $relative;
if (is_file($file) && !$registry::isLoaded($file)) {
$router->group(['prefix' => '/' . strtolower($prefix), 'middleware' => $middleware], static function (Router $r) use ($file): void {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($file) { require $file; })($r);
});
}
};
$autoInclude('web.php', $mod);
// api.php auto-mounted under /api/<module> with 'api' middleware group
$autoInclude('api.php', 'api/' . $mod, ['api']);
}
}
// Allow providers to contribute routes
$registry::apply($r, $router);
// Ensure default demo routes exist for acceptance/demo
$r->addRoute('GET', '/_phred/health', Controllers\HealthController::class);
$r->addRoute('GET', '/_phred/format', Controllers\FormatController::class);
};
}
}

View file

@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware\Cache;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Psr\SimpleCache\CacheInterface;
final class ResponseCacheMiddleware implements MiddlewareInterface
{
public function __construct(
private CacheInterface $cache,
private int $ttl = 3600
) {}
public function process(Request $request, Handler $handler): ResponseInterface
{
// Only cache GET requests
if ($request->getMethod() !== 'GET') {
return $handler->handle($request);
}
$cacheKey = $this->generateCacheKey($request);
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
$response = $this->unserializeResponse($cached);
// Handle ETag / If-None-Match
$etag = $response->getHeaderLine('ETag');
if ($etag && ($request->getHeaderLine('If-None-Match') === $etag)) {
return (new \Nyholm\Psr7\Factory\Psr17Factory())->createResponse(304);
}
return $response;
}
$response = $handler->handle($request);
// Only cache successful responses
if ($response->getStatusCode() === 200) {
$this->cache->set($cacheKey, $this->serializeResponse($response), $this->ttl);
}
return $response;
}
private function generateCacheKey(Request $request): string
{
$accept = $request->getHeaderLine('Accept');
$format = $request->getAttribute('phred.api_format', '');
return 'res_cache_' . sha1($request->getUri()->getPath() . '|' . $request->getUri()->getQuery() . '|' . $accept . '|' . $format);
}
private function serializeResponse(ResponseInterface $response): string
{
return serialize([
'status' => $response->getStatusCode(),
'headers' => $response->getHeaders(),
'body' => (string) $response->getBody(),
]);
}
private function unserializeResponse(string $serialized): ResponseInterface
{
$data = unserialize($serialized);
$factory = new \Nyholm\Psr7\Factory\Psr17Factory();
$response = $factory->createResponse($data['status']);
foreach ($data['headers'] as $name => $values) {
foreach ($values as $value) {
$response = $response->withAddedHeader($name, $value);
}
}
$response->getBody()->write($data['body']);
return $response;
}
}

View file

@ -1,72 +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;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Compresses the response body if the client supports it and compression is enabled.
*/
final class CompressionMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$enabled = filter_var((string) Config::get('COMPRESSION_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN);
if (!$enabled) {
return $response;
}
// Avoid re-compressing
if ($response->hasHeader('Content-Encoding')) {
return $response;
}
$acceptEncoding = $request->getHeaderLine('Accept-Encoding');
// Brotli support (if extension available)
if (str_contains($acceptEncoding, 'br') && function_exists('brotli_compress')) {
$level = (int) Config::get('COMPRESSION_LEVEL_BROTLI', 4);
$compressed = brotli_compress((string) $response->getBody(), $level);
if ($compressed !== false) {
return $this->withCompressedBody($response, $compressed, 'br');
}
}
// Gzip support
if (str_contains($acceptEncoding, 'gzip') && function_exists('gzencode')) {
$level = (int) Config::get('COMPRESSION_LEVEL_GZIP', -1);
$compressed = gzencode((string) $response->getBody(), $level);
if ($compressed !== false) {
return $this->withCompressedBody($response, $compressed, 'gzip');
}
}
// Deflate support
if (str_contains($acceptEncoding, 'deflate') && function_exists('gzdeflate')) {
$level = (int) Config::get('COMPRESSION_LEVEL_DEFLATE', -1);
$compressed = gzdeflate((string) $response->getBody(), $level);
if ($compressed !== false) {
return $this->withCompressedBody($response, $compressed, 'deflate');
}
}
return $response;
}
private function withCompressedBody(ResponseInterface $response, string $compressed, string $encoding): ResponseInterface
{
$stream = (new \Nyholm\Psr7\Factory\Psr17Factory())->createStream($compressed);
return $response
->withHeader('Content-Encoding', $encoding)
->withHeader('Content-Length', (string) strlen($compressed))
->withBody($stream);
}
}

View file

@ -1,60 +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
{
$cfg = $this->config ?? new DefaultConfig();
$defaultFormat = strtolower((string) $cfg->get('API_FORMAT', 'rest'));
$format = $this->profileSelf(function () use ($request, $defaultFormat) {
// First check if a format hint was already set by UrlExtensionNegotiationMiddleware
$hint = $request->getAttribute('phred.format_hint');
if ($hint && $hint !== 'html') {
return $hint;
}
// 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;
});
// Ensure RequestContext is updated so DelegatingApiResponseFactory sees the change
$request = $request->withAttribute(self::ATTR_API_FORMAT, $format);
// SYNC: Update RequestContext so that any service resolving it (like DelegatingApiResponseFactory)
// gets the request with the phred.api_format attribute.
\Phred\Http\RequestContext::set($request);
return $handler->handle($request);
}
}

View file

@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use DI\ContainerBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
use Phred\Http\RequestContext;
use Phred\Http\Responses\JsonApiResponseFactory;
use Phred\Http\Responses\RestResponseFactory;
use Phred\Http\Responses\XmlResponseFactory;
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 \DI\Container $container,
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);
}
$format = (string) ($request->getAttribute('phred.api_format', 'rest'));
$callable = $this->resolveCallable($handlerSpec, $this->container);
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 resolveCallable(mixed $handlerSpec, \DI\Container $requestContainer): callable
{
if (is_string($handlerSpec) && class_exists($handlerSpec)) {
$controller = $requestContainer->get($handlerSpec);
return [$controller, '__invoke'];
}
if (is_callable($handlerSpec)) {
return $handlerSpec;
}
throw new \RuntimeException('Invalid route handler. Phred requires invokable controllers (string) or a valid callable.');
}
private function invokeCallable(callable $callable, ServerRequest $request, array $vars): mixed
{
return $callable($request, ...array_values($vars));
}
}

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware\JsonApi;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
final class JsonApiQueryMiddleware implements MiddlewareInterface
{
public const ATTR_INCLUDE = 'jsonapi.include';
public const ATTR_FIELDS = 'jsonapi.fields';
public const ATTR_SORT = 'jsonapi.sort';
public const ATTR_FILTER = 'jsonapi.filter';
public function process(Request $request, Handler $handler): ResponseInterface
{
$params = $request->getQueryParams();
if (isset($params['include'])) {
$request = $request->withAttribute(self::ATTR_INCLUDE, explode(',', (string) $params['include']));
}
if (isset($params['fields']) && is_array($params['fields'])) {
$fields = [];
foreach ($params['fields'] as $type => $value) {
$fields[$type] = explode(',', (string) $value);
}
$request = $request->withAttribute(self::ATTR_FIELDS, $fields);
}
if (isset($params['sort'])) {
$request = $request->withAttribute(self::ATTR_SORT, explode(',', (string) $params['sort']));
}
if (isset($params['filter'])) {
$request = $request->withAttribute(self::ATTR_FILTER, (array) $params['filter']);
}
return $handler->handle($request);
}
}

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,69 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
use Phred\Support\Contracts\ConfigInterface;
use DI\Container;
use Relay\Relay;
final class MiddlewareGroupMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly ConfigInterface $config,
private readonly Container $container
) {}
public function process(Request $request, Handler $handler): ResponseInterface
{
$groups = (array) $request->getAttribute('phred.route.middleware', []);
if (empty($groups)) {
return $handler->handle($request);
}
$allMiddleware = [];
$configGroups = $this->config->get('middleware.groups', []);
$configAliases = $this->config->get('middleware.aliases', []);
foreach ($groups as $group) {
if (isset($configGroups[$group])) {
foreach ($configGroups[$group] as $m) {
$allMiddleware[] = $this->resolve($m);
}
} elseif (isset($configAliases[$group])) {
$allMiddleware[] = $this->resolve($configAliases[$group]);
} else {
// Assume it's a FQCN
$allMiddleware[] = $this->resolve($group);
}
}
if (empty($allMiddleware)) {
return $handler->handle($request);
}
// We wrap the remaining handler as a middleware to continue the outer pipeline
$allMiddleware[] = new class($handler) implements MiddlewareInterface {
public function __construct(private Handler $handler) {}
public function process(Request $request, Handler $handler): ResponseInterface
{
return $this->handler->handle($request);
}
};
$relay = new Relay($allMiddleware);
return $relay->handle($request);
}
private function resolve(mixed $middleware): MiddlewareInterface
{
if (is_string($middleware)) {
return $this->container->get($middleware);
}
return $middleware;
}
}

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,50 +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, $spec, $vars] = $routeInfo;
$handlerSpec = is_array($spec) && isset($spec['handler']) ? $spec['handler'] : $spec;
$middleware = is_array($spec) && isset($spec['middleware']) ? (array)$spec['middleware'] : [];
$name = is_array($spec) && isset($spec['name']) ? (string)$spec['name'] : null;
$request = $request
->withAttribute('phred.route.handler', $handlerSpec)
->withAttribute('phred.route.vars', $vars)
->withAttribute('phred.route.middleware', $middleware)
->withAttribute('phred.route.name', $name);
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,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Phred\Support\Contracts\ConfigInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
final class TrustedProxiesMiddleware implements MiddlewareInterface
{
public function __construct(private ConfigInterface $config) {}
public function process(Request $request, Handler $handler): ResponseInterface
{
$trustedProxies = (array) $this->config->get('security.trusted_proxies', []);
if (empty($trustedProxies)) {
return $handler->handle($request);
}
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
if ($this->isTrusted($remoteAddr, $trustedProxies)) {
$forwardedFor = $request->getHeaderLine('X-Forwarded-For');
if ($forwardedFor) {
// In a real implementation, we'd update the Request's client address.
// PSR-7 requests don't have a standard 'clientAddress' attribute,
// but many frameworks use 'ip' or similar.
}
}
return $handler->handle($request);
}
private function isTrusted(string $ip, array $trusted): bool
{
if (in_array('*', $trusted, true)) {
return true;
}
return in_array($ip, $trusted, true);
}
}

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' => 'rest',
'xml' => 'xml',
'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, array $headers = []): ResponseInterface
{
return $this->delegate()->fromArray($payload, $status, $headers);
}
private function delegate(): ApiResponseFactoryInterface
{
$req = RequestContext::get();
$format = $req?->getAttribute('phred.api_format') ?? 'rest';
return match ($format) {
'jsonapi' => $this->jsonapi,
'xml' => $this->xml,
default => $this->rest,
};
}
}

View file

@ -1,67 +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) {}
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, array $headers = []): ResponseInterface
{
// Caller must ensure payload is a valid JSON:API document shape
$res = $this->document($payload, $status);
foreach ($headers as $name => $value) {
$res = $res->withHeader($name, $value);
}
return $res;
}
/**
* @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,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 RestResponseFactory implements ApiResponseFactoryInterface
{
public function __construct(private Psr17Factory $psr17) {}
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, array $headers = []): ResponseInterface
{
$res = $this->json($payload, $status);
foreach ($headers as $name => $value) {
$res = $res->withHeader($name, $value);
}
return $res;
}
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,68 +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, array $headers = []): ResponseInterface
{
$res = $this->xml($payload, $status);
foreach ($headers as $name => $value) {
$res = $res->withHeader($name, $value);
}
return $res;
}
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,83 +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
{
private array $groupMiddleware = [];
public function __construct(private RouteCollector $collector, array $groupMiddleware = [])
{
$this->groupMiddleware = $groupMiddleware;
}
public function get(string $path, string|callable $handler, array $options = []): void
{
$this->addRoute('GET', $path, $handler, $options);
}
public function post(string $path, string|callable $handler, array $options = []): void
{
$this->addRoute('POST', $path, $handler, $options);
}
public function put(string $path, string|callable $handler, array $options = []): void
{
$this->addRoute('PUT', $path, $handler, $options);
}
public function patch(string $path, string|callable $handler, array $options = []): void
{
$this->addRoute('PATCH', $path, $handler, $options);
}
public function delete(string $path, string|callable $handler, array $options = []): void
{
$this->addRoute('DELETE', $path, $handler, $options);
}
private function addRoute(string $method, string $path, string|callable $handler, array $options): void
{
$middleware = $options['middleware'] ?? [];
if (is_string($middleware)) {
$middleware = [$middleware];
}
$middleware = array_merge($this->groupMiddleware, $middleware);
$spec = [
'handler' => $handler,
'middleware' => $middleware,
'name' => $options['name'] ?? null,
];
$this->collector->addRoute($method, $path, $spec);
}
/**
* Group routes under a common path prefix and/or middleware.
*
* Example:
* $router->group('/api', function (Router $r) { ... });
* $router->group(['prefix' => '/api', 'middleware' => 'api'], function (Router $r) { ... });
*/
public function group(string|array $attributes, callable $routes): void
{
$prefix = is_array($attributes) ? ($attributes['prefix'] ?? '') : $attributes;
$middleware = is_array($attributes) ? ($attributes['middleware'] ?? []) : [];
if (is_string($middleware)) {
$middleware = [$middleware];
}
$newMiddleware = array_merge($this->groupMiddleware, $middleware);
$this->collector->addGroup($prefix, function (RouteCollector $rc) use ($routes, $newMiddleware): void {
$routes(new Router($rc, $newMiddleware));
});
}
}

View file

@ -1,22 +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, ?string $file = null): void
{
if ($file) {
RouteRegistry::markAsLoaded($file);
}
$router->group($prefix, static function (Router $r) use ($loader): void {
$loader($r);
});
}
}

View file

@ -1,48 +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 = [];
/** @var array<string,bool> */
private static array $loadedFiles = [];
public static function markAsLoaded(string $filePath): void
{
self::$loadedFiles[realpath($filePath) ?: $filePath] = true;
}
public static function isLoaded(string $filePath): bool
{
return isset(self::$loadedFiles[realpath($filePath) ?: $filePath]);
}
public static function add(callable $registrar): void
{
self::$callbacks[] = $registrar;
}
public static function clear(): void
{
self::$callbacks = [];
self::$loadedFiles = [];
}
public static function apply(RouteCollector $collector, Router $router): void
{
foreach (self::$callbacks as $cb) {
$cb($collector, $router);
}
}
}

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
trait ConditionalRequestTrait
{
/**
* Check if the request is fresh based on ETag.
*/
protected function isFresh(Request $request, string $etag): bool
{
$ifNoneMatch = $request->getHeaderLine('If-None-Match');
return $ifNoneMatch === $etag || $ifNoneMatch === '"' . $etag . '"';
}
/**
* Generate an ETag for the given data.
*/
protected function generateEtag(mixed $data): string
{
return '"' . md5(serialize($data)) . '"';
}
}

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,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\OpenApi;
use OpenApi\Attributes as OA;
#[OA\Info(title: "Phred API", version: "0.1")]
#[OA\Server(url: "http://localhost:8000", description: "Local Development")]
#[OA\SecurityScheme(
securityScheme: "bearerAuth",
type: "http",
name: "Authorization",
in: "header",
bearerFormat: "JWT",
scheme: "bearer"
)]
class Spec
{
}

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,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Cache\FileCache;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
use Psr\SimpleCache\CacheInterface;
final class CacheServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$cacheDir = getcwd() . '/storage/cache';
$builder->addDefinitions([
CacheInterface::class => \DI\autowire(FileCache::class)
->constructor($cacheDir),
]);
}
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 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,31 +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;
use Phred\Http\Controllers\OpenApiJsonController;
use Phred\Http\Controllers\OpenApiUiController;
final class OpenApiServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
// No special bindings needed for OpenApi controllers as they can be autowired
}
public function boot(Container $container): void
{
RouteRegistry::add(static function ($r, $router): void {
$router->group('/_phred', static function ($router): void {
$router->get('/openapi', OpenApiJsonController::class);
$router->get('/openapi.json', OpenApiJsonController::class);
$router->get('/docs', OpenApiUiController::class);
});
});
}
}

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,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Phred\Support\PhpStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Phred\Mvc\Controller;
/**
* @implements Rule<Class_>
*/
final class InvokableControllerRule implements Rule
{
/** @var \PHPStan\Reflection\ReflectionProvider */
private $reflectionProvider;
public function __construct(\PHPStan\Reflection\ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if ($node->isAbstract() || $node->isAnonymous()) {
return [];
}
$className = $node->namespacedName ? $node->namespacedName->toString() : null;
if (!$className) {
return [];
}
// Use PHPStan's reflection to avoid issues with unindexed classes
if (!$scope->isInClass()) {
// For the Class_ node, we can get the reflection from the namespacedName
if (!$this->reflectionProvider->hasClass($className)) {
return [];
}
$classReflection = $this->reflectionProvider->getClass($className);
} else {
$classReflection = $scope->getClassReflection();
}
// Handle both Project\Modules and Modules (depending on setup)
$isControllerNamespace = str_contains($className, 'Controllers');
if (!$classReflection->isSubclassOf(Controller::class) && !$isControllerNamespace) {
return [];
}
$errors = [];
// Check public methods
foreach ($classReflection->getNativeReflection()->getMethods() as $method) {
if (!$method->isPublic()) {
continue;
}
// Only check methods defined in this class
if ($method->getDeclaringClass()->getName() !== $className) {
continue;
}
$methodName = $method->getName();
if ($methodName !== '__invoke' && $methodName !== '__construct') {
$errors[] = RuleErrorBuilder::message(sprintf(
'Controller "%s" has a non-invokable public method "%s". Phred strictly enforces the "One Controller = One Route" (invokable) pattern.',
$className,
$methodName
))->build();
}
}
// Also check if __invoke is missing
if (!$classReflection->hasNativeMethod('__invoke')) {
$errors[] = RuleErrorBuilder::message(sprintf(
'Controller "%s" is missing the required "__invoke" method.',
$className
))->build();
}
return $errors;
}
}

View file

@ -1,128 +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', []))));
$loadedClasses = [];
foreach ([$core, $app, $modules] as $group) {
foreach ($group as $class) {
if (is_string($class) && class_exists($class) && !isset($loadedClasses[$class])) {
$instance = new $class();
if ($instance instanceof ServiceProviderInterface) {
$this->providers[] = $instance;
$loadedClasses[$class] = true;
}
}
}
}
// 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;
}
$namespace = $this->resolveModuleNamespace($modulePath, $entry);
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 = "{$namespace}Providers\\\\{$classBase}";
if (class_exists($fqcn) && !isset($loadedClasses[$fqcn])) {
$instance = new $fqcn();
if ($instance instanceof ServiceProviderInterface) {
$this->providers[] = $instance;
$loadedClasses[$fqcn] = true;
}
}
}
}
}
}
private function resolveModuleNamespace(string $modulePath, string $moduleName): string
{
$composerFile = $modulePath . '/composer.json';
if (is_file($composerFile)) {
$data = json_decode((string)file_get_contents($composerFile), true);
if (is_array($data) && isset($data['autoload']['psr-4'])) {
foreach ($data['autoload']['psr-4'] as $ns => $path) {
// If the path is empty or '.', it maps the root of the module to this namespace
if ($path === '' || $path === '.' || $path === './') {
return rtrim($ns, '\\') . '\\';
}
}
}
}
// Fallback to default Phred convention
$baseNamespace = (string) $this->config->get('MODULE_NAMESPACE', 'Modules');
return "{$baseNamespace}\\\\{$moduleName}\\\\";
}
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;
}
}

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