initial commit

This commit is contained in:
Funky Waddle 2025-12-14 17:10:01 -06:00
parent 0a22ea34cb
commit 3452ac1e12
29 changed files with 1610 additions and 4 deletions

16
.editorconfig Normal file
View file

@ -0,0 +1,16 @@
# 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

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
APP_NAME=Phred App
APP_ENV=local
APP_DEBUG=true
APP_TIMEZONE=UTC
API_FORMAT=rest

10
.gitattributes vendored Normal file
View file

@ -0,0 +1,10 @@
# 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

56
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,56 @@
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

56
.gitignore vendored
View file

@ -2,9 +2,9 @@
composer.phar composer.phar
/vendor/ /vendor/
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control # Template policy: do not commit composer.lock in this template repo
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file # (apps generated from this template should commit THEIR lock file)
# composer.lock composer.lock
# ---> JetBrains # ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
@ -85,3 +85,53 @@ fabric.properties
# Android studio 3.1+ serialized cache file # Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser .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/
# Codeception outputs
tests/_output/
tests/_support/_generated/
/.env
/.phpunit.cache
/.php-cs-fixer.cache

163
MILESTONES.md Normal file
View file

@ -0,0 +1,163 @@
# Phred Framework Milestones
Phred supports REST and JSON:API via env setting; batteries-included defaults, swappable components.
## ~~M0 — Project bootstrap (repo readiness)~~
* ~~Tasks:~~
* ~~Finalize `composer.json` (namespaces, scripts, suggests) and `LICENSE`.~~
* ~~Add `.editorconfig`, `.gitattributes`, `.gitignore`, example `.env.example`.~~
* ~~Set up CI (lint, static analysis, unit tests) and basic build badge.~~
* ~~Acceptance:~~
* ~~Fresh clone installs (without running suggested packages) and passes linters/analysis/tests.~~
## ~~M1 — Core HTTP kernel and routing~~
* ~~Tasks:~~
* ~~Implement the HTTP kernel: `PSR-15` pipeline via `Relay`.~~
* ~~Wire `nyholm/psr7(-server)` factories and server request creation.~~
* ~~Integrate `nikic/fast-route` with a RouteCollector and dispatcher.~~
* ~~Define route → controller resolution (invokable controllers).~~
* ~~Add minimal app bootstrap (front controller) and DI container wiring (`PHP-DI`).~~
* ~~Acceptance:~~
* ~~Sample route returning a JSON 200 via controller.~~
* ~~Controllers are invokable (`__invoke(Request)`), one route per controller.~~
## M2 — Configuration and environment
* Tasks:
* Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`.
* Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).
* Acceptance:
* App reads config from `.env`; unit test demonstrates override behavior.
## M3 — API formats and content negotiation
* Tasks:
* Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.
* Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.
* Provide developerfacing helpers for common responses (`ok`, `created`, `error`).
* Acceptance:
* Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.
## M4 — Error handling and problem details
* Tasks:
* Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.
* Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).
* Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.
* Acceptance:
* Throwing an exception yields a standardscompliant error response; debug mode shows Whoops page.
## M5 — Dependency Injection and Service Providers
* Tasks:
* Define Service Provider interface and lifecycle (register, boot).
* Module discovery loads providers in order (core → app → module).
* Add examples for registering controllers, services, config, and routes via providers.
* Define contracts: `Phred\Contracts\Template\RendererInterface`, `Phred\Contracts\Orm\*`, `Phred\Contracts\Flags\FeatureFlagClientInterface`, `Phred\Contracts\Testing\TestRunnerInterface` (optional).
* Define config/env keys for driver selection (e.g., `TEMPLATE_DRIVER`, `ORM_DRIVER`, `FLAGS_DRIVER`, `TEST_RUNNER`).
* Provide “default adapter” Service Providers for the shipped packages and document swap procedure.
* Acceptance:
* Providers can contribute bindings and routes; order is deterministic and tested.
* A sample module can switch template/ORM/flags provider by changing `.env` and provider registration, without touching controllers/services.
## M6 — MVC: Controllers, Views, Templates
* Tasks:
* Controller base class and conventions (request/response helpers).
* View layer (data preparation) with `getphred/eyrie` template engine integration.
* Template rendering helper: `$this->render(<template>, <data>)`.
* Acceptance:
* Example page rendered through View → Template; API coexists with fullsite rendering.
* Rendering works via RendererInterface and can be swapped (e.g., Eyrie → Twig demo) with only configuration/provider changes.
## M7 — Modules (Djangostyle app structure)
* Tasks:
* Define module filesystem layout (Nested Controllers/Views/Services/Models/Templates/Routes/Tests).
* Module loader: autoregister providers, routes, templates.
* Namespacing and autoload guidance.
* Acceptance:
* Creating a module with the CLI makes it discoverable; routes/templates work without manual wiring.
## M8 — Database access, migrations, and seeds
* Tasks:
* Integrate `getphred/pairity` for ORM/migrations/seeds.
* Define config (`DB_*`), migration paths (app and modules), and seeder conventions.
* CLI commands: `migrate`, `migration:rollback`, `seed`, `seed:rollback`.
* All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).
* Acceptance:
* Running migrations modifies a test database; seeds populate sample data; CRUD demo works.
* All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).
## M9 — CLI (phred) and scaffolding
* Tasks:
* Implement Symfony Console app in `bin/phred`.
* Generators: `create:module`, `create:<module>:controller`, `create:<module>:model`, `create:<module>:migration`, `create:<module>:seed`, `create:<module>:test`, `create:<module>:view`.
* Utility commands: `test[:<module>]`, `run`, `db:backup`, `db:restore`.
* Acceptance:
* Commands generate files with correct namespaces/paths and pass basic smoke tests.
## M10 — Security middleware and auth primitives
* Tasks:
* Add CORS, Secure Headers middlewares; optional CSRF for template routes.
* JWT support (lcobucci/jwt) with simple token issue/verify service.
* Configuration for CORS origins, headers, methods.
* Bind FeatureFlagClientInterface with a default adapter (Flagpole); add small sample usage and env config.
* Acceptance:
* CORS preflight and secured endpoints behave as configured; JWTprotected route example works.
## M11 — Logging, HTTP client, and filesystem
* Tasks:
* Monolog setup with handlers and processors (request ID, memory, timing).
* Guzzle PSR18 client exposure; DI binding for HTTP client interface.
* Flysystem integration with local adapter; abstraction for storage disks.
* Acceptance:
* Logs include correlation IDs; sample outbound HTTP call via client; file upload/storage demo works.
## M12 — Serialization/validation utilities and pagination
* Tasks:
* REST default: Symfony Serializer normalizers/encoders; document extension points.
* Add simple validation layer (pick spec or integrate later if preferred; at minimum, input filtering and error shape alignment with Problem Details).
* Pagination helpers (links/meta), REST and JSON:API compatible outputs.
* Acceptance:
* Example endpoint validates input, returns 422 with details; paginated listing includes links/meta.
## M13 — OpenAPI and documentation
* Tasks:
* Integrate `zircote/swagger-php` annotations.
* CLI/task to generate OpenAPI JSON; optional serve route and Redoc UI pointer.
* Document auth, pagination, error formats.
* Acceptance:
* Generated OpenAPI document validates; matches sample endpoints.
## M14 — Testing, quality, and DX
* Tasks:
* Establish testing structure with Codeception (unit, integration, API suites).
* Add fixtures/factories via Faker for examples.
* PHPStan level selection and baseline; code style via php-cs-fixer ruleset.
* Precommit hooks (e.g., GrumPHP) optional.
* Define TestRunnerInterface and a Codeception adapter; otherwise, state tests are run via Composer script only.
* Acceptance:
* `composer test` runs green across suites; static analysis passes.
* CLI tests run via TestRunnerInterface;
* CLI tests run green per module and across suites.
## M15 — Caching and performance (optional default)
* Tasks:
* Provide `PSR-16` cache interface binding; suggest `symfony/cache` when enabled.
* Simple response caching middleware and ETag/LastModified helpers.
* Rate limiting middleware (token bucket) suggestion/integration point.
* Acceptance:
* Sample endpoint demonstrates cached responses and conditional requests.
## M16 — Production hardening and deployment
* Tasks:
* Config for envs (dev/test/stage/prod), error verbosity, trusted proxies/hosts.
* Docker example, PHPFPM + Nginx config templates.
* Healthcheck endpoint, readiness/liveness probes.
* Acceptance:
* Containerized demo serves both API and template pages; healthchecks pass.
## M17 — JSON:API enhancements (optional package)
* Tasks:
* If enabled, integrate `neomerx/json-api` fully: includes, sparse fieldsets, relationships, sorting, filtering, pagination params.
* Adapters/Schema providers per resource type.
* Acceptance:
* JSON:API conformance tests for selected endpoints pass; docs updated.
## M18 — Examples and starter template
* Tasks:
* Create `examples/blog` module showcasing controllers, views, templates, ORM, auth, pagination, and both API formats.
* Provide `composer create-project` skeleton template instructions.
* Acceptance:
* New users can scaffold a working app in minutes following README.
## M19 — Documentation site
* Tasks:
* Expand README into a docs site (MkDocs or similar): getting started, concepts, reference, guides.
* Versioned docs and upgrade notes.
* Acceptance:
* Docs published; links in README; examples maintained.
## M20 — Governance and roadmap tracking
* Tasks:
* Define contribution guide, issue templates, RFC process for changes.
* Public roadmap (this milestone list) tracked as GitHub Projects/Issues.
* Acceptance:
* Contributors can propose features via RFC; roadmap is visible and updated.
## Notes on sequencing and parallelization
* M0M4 are critical path for the HTTP core and should be completed sequentially.
* M5M8 can progress in parallel with M9 (CLI) once the kernel is stable.
* Optional tracks (M15, M17) can be deferred without blocking core usability.

124
README.md
View file

@ -1,3 +1,125 @@
# Phred # Phred
A PHP MVC framework with invokable controllers (Actions), Views are classes for data manipulation before rendering Templates, Models are partitioned between DAO and DTO objects. And Modular separation, similar to Django apps. A PHP MVC framework:
* Intended for projects of all sizes, and solo or team development.
* The single router call per controller style makes it easy for teamwork without stepping on each others toes.
* REQUIREMENTS
* PHP 8.1+
* Primarily meant for Apache/Nginx webservers, will look into supporting other webservers in the future.
* PSR-4 autoloading.
* Installed through Composer (`composer create-project getphred/phred`)
* Environment variables (.env) for configuration.
* Supports two API formats
* pragmatic REST (default)
* JSON:API
* Choose via .env:
* `API_FORMAT=rest` (plain JSON responses, RFC7807 error format.)
* `API_FORMAT=jsonapi` (JSON:API compliant documents and error objects.)
* You may also negotiate per request using the `Accept` header.
* TESTING environment variables (.env)
* `TEST_RUNNER=codeception`
* `TEST_PATH=tests`
* `TEST_PATH` is relative to both project root and each module root.
* Dependency Injection
* Fully Pluggable, but ships with defaults:
* Pluggability model
* Core depends on Phred contracts (`Phred\Contracts\*`) and PSRs
* Concrete implementations are provided by Service Providers.
* Swap packages by changing `.env` and enabling a provider.
* Driver keys (examples)
* `ORM_DRIVER=pairity|doctrine`
* `TEMPLATE_DRIVER=eyrie|twig|plates`
* `FLAGS_DRIVER=flagpole|unleash`
* `TEST_RUNNER=codeception`
* Primary contracts
* `Template\RendererInterface`
* `Orm\EntityManagerInterface` (or repositories)
* `Flags\FeatureFlagClientInterface`
* `Testing\TestRunnerInterface`.
* Default Plug-ins
* Feature Flags through `getphred/flagpole`
* ORM through `getphred/pairity` (handles migrations, seeds, and db access)
* Unit Testing through `codeception/codeception`
* Testing is provided as a CLI dev capability only; it is not part of the HTTP request lifecycle.
* Template Engine through `getphred/eyrie`
* Other dependencies:
* Dependency Injection through `php-di/php-di`
* Static Analysis through `phpstan/phpstan`
* Code Style Enforcement through `friendsofphp/php-cs-fixer`
* Logging through `monolog/monolog`
* Config and environment handling through `vlucas/phpdotenv`
* HTTP client through `guzzlehttp/guzzle`
* CONTROLLERS
* Invokable controllers (Actions),
* Single router call per controller,
* `public function __invoke(Request $request)` method entry point on controller class,
* VIEWS
* Classes for data manipulation/preparation before rendering Templates,
* `$this->render(<template_name>, <data_array>);` to render a template.
* SERVICES
* for business logic.
* SERVICE PROVIDERS
* for dependency injection.
* MIGRATIONS
* for database changes.
* Modular separation, similar to Django apps.
* Nested Models
* Nested Controllers
* Nested Views
* Nested Services
* Nested Migrations
* Nested Service Providers
* Nested Routes
* Nested Templates
* Nested Tests
* CLI Helper called phred
* `php phred create:command <name>` // Creates a CLI command under `console/commands
* `php phred create:module <name>` // Creates a module
* `php phred create:<module>:controller` // Creates a controller in the specified module
* `php phred create:<module>:model` // Creates a model in the specified module
* `php phred create:<module>:migration` // Creates a migration in the specified module
* `php phred create:<module>:seed` // Creates a seeder in the specified module
* `php phred create:<module>:test` // Creates a test in the specified module
* `php phred create:<module>:view` / Creates a view in the specified module
* `php phred db:backup` // Backup the database
* `php phred db:restore -f <db_backup_file>` // Restore the database from the specified backup file
* `php phred migrate [-m <module>]` // Migrate entire project or module
* `php phred migration:rollback [-m <module>]` // Rollback entire project or module
* `php phred seed`
* `php phred seed:rollback`
* `php phred test[:<module>]` // Test entire project or module
* Runs tests using the configured test runner (dev only).
* Requires `require-dev` dependencies.
* `php phred run [-p <port>]`
* Spawns a local PHP webserver on port 8000 (unless specified otherwise using `-p`)
* CLI Helper is extendable through CLI Commands.
Command discovery
* Core commands (bundled with Phred) are discovered from `src/commands`.
* User/project commands are discovered from `console/commands` in your project root.
Run the CLI:
```
php phred list
```
Add your own command by creating a PHP file under `console/commands`, returning an instance of `Phred\Console\Command` (or an anonymous class extending it).
(Or by running `php phred create:command <name>`)
Example:
```
<?php
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'hello:world';
protected string $description = 'Example user command';
public function handle(Input $in, Output $out): int { $out->writeln('Hello!'); return 0; }
};
```

51
bin/phred Normal file
View file

@ -0,0 +1,51 @@
#!/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';
if (is_dir($coreDir)) {
foreach (glob($coreDir . '/*.php') as $file) {
$cmd = require $file;
if ($cmd instanceof \Phred\Console\Command) {
$app->add($cmd->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();
}

86
composer.json Normal file
View file

@ -0,0 +1,86 @@
{
"name": "getphred/phred",
"description": "Phred — API-first PHP framework with optional Views/Templates and batteries-included defaults.",
"type": "project",
"license": "MIT",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"psr/http-message": "^1.1",
"psr/http-factory": "^1.0",
"psr/http-server-middleware": "^1.0",
"psr/http-server-handler": "^1.0",
"psr/container": "^2.0",
"psr/log": "^3.0",
"psr/simple-cache": "^3.0",
"psr/cache": "^3.0",
"psr/clock": "^1.0",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.0",
"relay/relay": "^2.0",
"nikic/fast-route": "^1.3",
"php-di/php-di": "^7.0",
"monolog/monolog": "^3.6",
"vlucas/phpdotenv": "^5.6",
"symfony/serializer": "^7.1",
"crell/api-problem": "^3.0",
"middlewares/cors": "^1.1 || ^2.0",
"lcobucci/jwt": "^5.0",
"symfony/console": "^7.1",
"league/flysystem": "^3.26",
"guzzlehttp/guzzle": "^7.9",
"zircote/swagger-php": "^4.10",
"filp/whoops": "^2.15",
"getphred/flagpole": "^0.1 || dev-main"
},
"require-dev": {
"codeception/codeception": "^5.0",
"phpstan/phpstan": "^1.11",
"friendsofphp/php-cs-fixer": "^3.64",
"fakerphp/faker": "^1.23"
},
"suggest": {
"neomerx/json-api": "Enable JSON:API mode for standardized API documents",
"symfony/cache": "Use PSR-6/16 cache implementations",
"doctrine/migrations": "Database migrations support",
"robmorgan/phinx": "Alternative database migrations tool",
"open-telemetry/opentelemetry-php": "Tracing/metrics/logs instrumentation",
"symfony/http-client": "PSR-18 capable HTTP client alternative to Guzzle",
"getphred/eyrie": "Template engine support for Views/Templates"
},
"autoload": {
"psr-4": {
"Phred\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Phred\\Tests\\": "tests/"
}
},
"scripts": {
"phred": "@php bin/phred",
"cs:fix": "php-cs-fixer fix",
"stan": "phpstan analyse",
"test": "codecept run",
"post-create-project-cmd": [
"@php bin/phred install"
],
"post-install-cmd": [
"@php bin/phred install"
]
},
"config": {
"sort-packages": true,
"allow-plugins": {
"phpstan/extension-installer": true
}
},
"extra": {
"phred": {
"apiFormatEnv": "API_FORMAT",
"apiFormatDefault": "rest"
}
}
}

8
phpstan.neon.dist Normal file
View file

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

3
phred Executable file
View file

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

2
phred.bat Normal file
View file

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

67
src/Console/Command.php Normal file
View file

@ -0,0 +1,67 @@
<?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 getDescription(): string { return $this->description; }
/** @return array<string,array> */
public function getOptions(): array { return $this->options; }
abstract public function handle(Input $input, Output $output): int;
public function toSymfony(): SymfonyCommand
{
$self = $this;
return new class($self->getName(), $self) extends SymfonyCommand {
public function __construct(private string $name, private Command $wrapped)
{
parent::__construct($name);
}
protected function configure(): void
{
$this->setDescription($this->wrapped->getDescription());
foreach ($this->wrapped->getOptions() as $key => $def) {
$mode = $def['mode'] ?? 'argument';
$description = $def['description'] ?? '';
$default = $def['default'] ?? null;
if ($mode === 'argument') {
$argMode = ($def['required'] ?? false)
? InputArgument::REQUIRED
: InputArgument::OPTIONAL;
$this->addArgument($key, $argMode, $description, $default);
} elseif ($mode === 'flag') {
$shortcut = $def['shortcut'] ?? null;
$this->addOption(ltrim($key, '-'), $shortcut, InputOption::VALUE_NONE, $description);
} else { // option
$shortcut = $def['shortcut'] ?? null;
$valueReq = $def['valueRequired'] ?? true;
$valueMode = $valueReq ? InputOption::VALUE_REQUIRED : InputOption::VALUE_OPTIONAL;
$this->addOption(ltrim($key, '-'), $shortcut, $valueMode, $description, $default);
}
}
}
protected function execute(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output): int
{
return $this->wrapped->handle($input, $output);
}
};
}
}

View file

@ -0,0 +1,30 @@
<?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

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Controllers;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
final class HealthController
{
public function __invoke(Request $request): ResponseInterface
{
$psr17 = new Psr17Factory();
$res = $psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
$res->getBody()->write(json_encode([
'ok' => true,
'framework' => 'Phred',
], JSON_UNESCAPED_SLASHES));
return $res;
}
}

View file

@ -0,0 +1,99 @@
<?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;
}
}

79
src/Http/Kernel.php Normal file
View file

@ -0,0 +1,79 @@
<?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();
$this->dispatcher = $dispatcher ?? $this->buildDispatcher();
}
public function container(): Container
{
return $this->container;
}
public function dispatcher(): Dispatcher
{
return $this->dispatcher;
}
public function handle(ServerRequest $request): ResponseInterface
{
$psr17 = new Psr17Factory();
$middleware = [
new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
new Middleware\DispatchMiddleware($this->container, $psr17),
];
$relay = new Relay($middleware);
return $relay->handle($request);
}
private function buildContainer(): Container
{
$builder = new ContainerBuilder();
// Add definitions/bindings here as needed.
return $builder->build();
}
private function buildDispatcher(): Dispatcher
{
$routesPath = dirname(__DIR__, 2) . '/routes';
$collector = static function (RouteCollector $r) use ($routesPath): void {
// Load user-defined routes if present
$router = new Router($r);
foreach (['web.php', 'api.php'] as $file) {
$path = $routesPath . '/' . $file;
if (is_file($path)) {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($path) { require $path; })($router);
}
}
// Ensure a default health route exists for acceptance/demo
$r->addRoute('GET', '/_phred/health', [Controllers\HealthController::class, '__invoke']);
};
return simpleDispatcher($collector);
}
}

View file

@ -0,0 +1,29 @@
<?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;
class ContentNegotiationMiddleware implements MiddlewareInterface
{
public const ATTR_API_FORMAT = 'phred.api_format'; // 'rest' | 'jsonapi'
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$format = strtolower(Config::get('API_FORMAT', Config::get('api.format', 'rest')));
// Optional: allow Accept header to override when JSON:API is explicitly requested
$accept = $request->getHeaderLine('Accept');
if (str_contains($accept, 'application/vnd.api+json')) {
$format = 'jsonapi';
}
return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format));
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use DI\Container;
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 DispatchMiddleware implements MiddlewareInterface
{
public function __construct(
private Container $container,
private Psr17Factory $psr17
) {}
public function process(ServerRequest $request, Handler $handler): ResponseInterface
{
$handlerSpec = $request->getAttribute('phred.route.handler');
$vars = $request->getAttribute('phred.route.vars', []);
if (!$handlerSpec) {
$response = $this->psr17->createResponse(500);
$response->getBody()->write(json_encode(['error' => 'No route handler'], JSON_UNESCAPED_SLASHES));
return $response->withHeader('Content-Type', 'application/json');
}
// Resolve controller from container if it's a class name array [class, method] or string class
if (is_array($handlerSpec) && is_string($handlerSpec[0])) {
$class = $handlerSpec[0];
$method = $handlerSpec[1] ?? '__invoke';
$controller = $this->container->get($class);
$callable = [$controller, $method];
} elseif (is_string($handlerSpec) && class_exists($handlerSpec)) {
$controller = $this->container->get($handlerSpec);
$callable = [$controller, '__invoke'];
} else {
$callable = $handlerSpec; // already a callable/closure
}
$response = $callable($request, ...array_values((array) $vars));
if (!$response instanceof ResponseInterface) {
// Normalize simple arrays/strings into JSON/text response for convenience
if (is_array($response)) {
$json = json_encode($response, 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) $response);
return $res;
}
return $response;
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Crell\ApiProblem\ApiProblem;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
use Phred\Support\Config;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
class ProblemDetailsMiddleware implements MiddlewareInterface
{
public function __construct(private readonly bool $debug = false)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
return $handler->handle($request);
} catch (Throwable $e) {
$useProblem = filter_var(Config::get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN);
$format = strtolower((string) $request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest'));
if ($this->debug) {
// In debug mode, include trace in detail to aid development.
$detail = $e->getMessage() . "\n\n" . $e->getTraceAsString();
} else {
$detail = $e->getMessage();
}
$status = $this->deriveStatus($e);
if ($useProblem && $format !== 'jsonapi') {
$problem = new ApiProblem($detail ?: 'An error occurred');
$problem->setType('about:blank');
$problem->setTitle($this->shortClass($e));
$problem->setStatus($status);
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']))->withBody($stream);
}
// JSON:API error response (or generic JSON when problem details disabled)
$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]))->withBody($stream);
}
}
private function deriveStatus(Throwable $e): int
{
$code = (int) ($e->getCode() ?: 500);
if ($code < 400 || $code > 599) {
return 500;
}
return $code;
}
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;
}
}

View file

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

View file

@ -0,0 +1,47 @@
<?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);
}
}

39
src/Http/Router.php Normal file
View file

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

33
src/Support/Config.php Normal file
View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Phred\Support;
final class Config
{
/**
* Get a config value from environment variables or return default.
* Accepts both UPPER_CASE and dot.notation keys (the latter is for future file-based config).
*/
public static function get(string $key, mixed $default = null): mixed
{
// Support dot.notation by converting to uppercase with underscores for env lookup.
$envKey = strtoupper(str_replace('.', '_', $key));
$value = getenv($envKey);
if ($value !== false) {
return $value;
}
// Fallback to server superglobal if present.
if (isset($_SERVER[$envKey])) {
return $_SERVER[$envKey];
}
if (isset($_ENV[$envKey])) {
return $_ENV[$envKey];
}
return $default;
}
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:command';
protected string $description = 'Scaffold a new user CLI command under console/commands.';
protected array $options = [
'name' => [
'mode' => 'argument',
'required' => true,
'description' => 'Command name (e.g., hello:world)',
],
'--force' => [
'mode' => 'flag',
'description' => 'Overwrite if the target file already exists.',
],
];
public function handle(Input $input, Output $output): int
{
$name = (string) ($input->getArgument('name') ?? '');
$force = (bool) $input->getOption('force');
$name = trim($name);
if ($name === '') {
$output->writeln('<error>Command name is required.</error>');
return 1;
}
// Derive PascalCase filename from name, splitting on non-alphanumeric boundaries and colons/underscores/dashes
$parts = preg_split('/[^a-zA-Z0-9]+/', $name) ?: [];
$classStem = '';
foreach ($parts as $p) {
if ($p === '') { continue; }
$classStem .= ucfirst(strtolower($p));
}
if ($classStem === '') {
$output->writeln('<error>Unable to derive a valid filename from the provided name.</error>');
return 1;
}
$root = getcwd();
$dir = $root . '/console/commands';
$file = $dir . '/' . $classStem . '.php';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
if (file_exists($file) && !$force) {
$output->writeln('<error>Command already exists:</error> console/commands/' . basename($file));
$output->writeln('Use <comment>--force</comment> to overwrite.');
return 1;
}
$template = <<<'PHP'
<?php
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = '__COMMAND__';
protected string $description = 'Describe your command';
public function handle(Input $i, Output $o): int
{
$o->writeln('Command __COMMAND__ executed.');
return 0;
}
};
PHP;
$contents = str_replace('__COMMAND__', $name, $template);
@file_put_contents($file, rtrim($contents) . "\n");
$output->writeln('<info>created</info> console/commands/' . basename($file));
return 0;
}
};

189
src/commands/install.php Normal file
View file

@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'install';
protected string $description = 'Scaffold the Phred project structure (idempotent).';
protected array $options = [
'--force' => ['mode' => 'flag', 'description' => 'Overwrite existing files when scaffolding.'],
];
public function handle(Input $input, Output $output): int
{
$force = (bool) $input->getOption('force');
// Define placeholders to keep static analyzers from flagging variables used inside template strings
/** @var mixed $app */ $app = null;
/** @var mixed $router */ $router = null;
$root = getcwd();
$dirs = [
'public',
'bootstrap',
'config',
'routes',
'modules',
'resources',
'storage',
'storage/logs',
'storage/cache',
'storage/sessions',
'storage/views',
'storage/uploads',
'tests',
'console',
'console/commands',
];
foreach ($dirs as $d) {
$path = $root . DIRECTORY_SEPARATOR . $d;
if (!is_dir($path)) {
@mkdir($path, 0777, true);
$output->writeln("<info>created</info> $d/");
}
}
// .gitkeep for empty directories commonly empty
foreach (['modules', 'resources', 'storage/logs', 'storage/cache', 'storage/sessions', 'storage/views', 'storage/uploads', 'console/commands'] as $maybeEmpty) {
$file = $root . DIRECTORY_SEPARATOR . $maybeEmpty . DIRECTORY_SEPARATOR . '.gitkeep';
if (!file_exists($file)) {
@file_put_contents($file, "");
}
}
// Files to scaffold
$files = [
'public/index.php' => <<<'PHP'
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
// Bootstrap the application (container, pipeline, routes)
$app = require dirname(__DIR__) . '/bootstrap/app.php';
// TODO: Build a ServerRequest (Nyholm) and run Relay pipeline
PHP,
'bootstrap/app.php' => <<<'PHP'
<?php
declare(strict_types=1);
use Dotenv\Dotenv;
$root = dirname(__DIR__);
if (file_exists($root . '/vendor/autoload.php')) {
require $root . '/vendor/autoload.php';
}
if (file_exists($root . '/.env')) {
Dotenv::createImmutable($root)->safeLoad();
}
// TODO: Build and return an application kernel/closure
return static function () {
return null; // placeholder
};
PHP,
'config/app.php' => <<<'PHP'
<?php
declare(strict_types=1);
return [
'name' => getenv('APP_NAME') ?: 'Phred App',
'env' => getenv('APP_ENV') ?: 'local',
'debug' => (bool) (getenv('APP_DEBUG') ?: true),
'timezone' => getenv('APP_TIMEZONE') ?: 'UTC',
];
PHP,
'routes/web.php' => <<<'PHP'
<?php
declare(strict_types=1);
// Define web routes here
// Example (FastRoute style):
// $router->get('/', [HomeController::class, '__invoke']);
PHP,
'routes/api.php' => <<<'PHP'
<?php
declare(strict_types=1);
// Define API routes here
// Example: $router->get('/health', [HealthController::class, '__invoke']);
PHP,
'.env.example' => <<<'ENV'
APP_NAME=Phred App
APP_ENV=local
APP_DEBUG=true
APP_TIMEZONE=UTC
API_FORMAT=rest
ENV,
];
foreach ($files as $relative => $contents) {
$path = $root . DIRECTORY_SEPARATOR . $relative;
if (!file_exists($path) || $force) {
if (!is_dir(dirname($path))) {
@mkdir(dirname($path), 0777, true);
}
@file_put_contents($path, rtrim($contents) . "\n");
$output->writeln("<info>wrote</info> $relative");
}
}
// Copy .env if missing
if (!file_exists($root . '/.env') && file_exists($root . '/.env.example')) {
@copy($root . '/.env.example', $root . '/.env');
$output->writeln('<info>created</info> .env');
}
// Root phred launcher (Unix)
$launcher = $root . '/phred';
if (!file_exists($launcher) || $force) {
$shim = "#!/usr/bin/env php\n<?php\nrequire __DIR__ . '/bin/phred';\n";
@file_put_contents($launcher, $shim);
@chmod($launcher, 0755);
$output->writeln('<info>wrote</info> phred');
}
// Windows launcher
$launcherWin = $root . '/phred.bat';
if (!file_exists($launcherWin) || $force) {
$shimWin = "@ECHO OFF\r\nphp \"%~dp0bin\\phred\" %*\r\n";
@file_put_contents($launcherWin, $shimWin);
$output->writeln('<info>wrote</info> phred.bat');
}
// Ensure .gitignore has sensible defaults
$gitignore = $root . '/.gitignore';
$giLines = [
"/vendor/",
"/.env",
"/storage/*",
"!/storage/.gitkeep",
"/.phpunit.cache",
"/.php-cs-fixer.cache",
];
$existing = file_exists($gitignore) ? file($gitignore, FILE_IGNORE_NEW_LINES) : [];
$set = $existing ? array_flip($existing) : [];
$changed = false;
foreach ($giLines as $line) {
if (!isset($set[$line])) {
$existing[] = $line;
$changed = true;
}
}
if ($changed) {
@file_put_contents($gitignore, implode("\n", $existing) . "\n");
$output->writeln('<info>updated</info> .gitignore');
}
$output->writeln("\n<comment>Phred scaffold complete.</comment>");
$output->writeln("Try: <info>./phred</info> or <info>php bin/phred</info>");
return 0;
}
};

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use PHPUnit\Framework\TestCase;
final class CommandDiscoveryTest extends TestCase
{
private string $userCmdPath;
protected function setUp(): void
{
$root = dirname(__DIR__);
$consoleDir = $root . '/console/commands';
if (!is_dir($consoleDir)) {
@mkdir($consoleDir, 0777, true);
}
$this->userCmdPath = $consoleDir . '/_DummyDiscoveryCmd.php';
$cmd = <<<'PHP'
<?php
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'dummy:discovery';
protected string $description = 'Dummy user command for discovery test';
public function handle(Input $i, Output $o): int { $o->writeln('ok'); return 0; }
};
PHP;
@file_put_contents($this->userCmdPath, $cmd);
}
protected function tearDown(): void
{
if (is_file($this->userCmdPath)) {
@unlink($this->userCmdPath);
}
}
public function testUserCommandIsDiscovered(): void
{
$root = dirname(__DIR__);
$cmd = 'php ' . escapeshellarg($root . '/bin/phred') . ' list';
$output = shell_exec($cmd) ?: '';
$this->assertStringContainsString('dummy:discovery', $output, 'User command from console/commands should be discovered');
}
}

28
tests/HttpKernelTest.php Normal file
View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
final class HttpKernelTest extends TestCase
{
public function testHealthEndpointReturnsJson200(): void
{
$root = dirname(__DIR__);
/** @var object $app */
$app = require $root . '/bootstrap/app.php';
$this->assertInstanceOf(\Phred\Http\Kernel::class, $app);
$request = new ServerRequest('GET', '/_phred/health');
$response = $app->handle($request);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
$data = json_decode((string) $response->getBody(), true);
$this->assertIsArray($data);
$this->assertArrayHasKey('ok', $data);
$this->assertTrue($data['ok']);
}
}

41
tests/MakeCommandTest.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use PHPUnit\Framework\TestCase;
final class MakeCommandTest extends TestCase
{
private string $createdPath;
protected function tearDown(): void
{
if ($this->createdPath && is_file($this->createdPath)) {
@unlink($this->createdPath);
}
}
public function testMakeCommandCreatesFileAndIsDiscovered(): void
{
$root = dirname(__DIR__);
$target = $root . '/console/commands/HelloWorld.php';
if (is_file($target)) {
@unlink($target);
}
$cmd = 'php ' . escapeshellarg($root . '/bin/phred') . ' make:command hello:world';
$output = shell_exec($cmd) ?: '';
$this->assertStringContainsString('created', $output, 'Expected creation message');
$this->createdPath = $target;
$this->assertFileExists($target, 'Scaffolded command file should exist');
$contents = @file_get_contents($target) ?: '';
$this->assertStringContainsString("protected string \$command = 'hello:world';", $contents);
// And the new command should be listed by the CLI
$listOut = shell_exec('php ' . escapeshellarg($root . '/bin/phred') . ' list') ?: '';
$this->assertStringContainsString('hello:world', $listOut);
}
}