initial commit
This commit is contained in:
parent
0a22ea34cb
commit
3452ac1e12
16
.editorconfig
Normal file
16
.editorconfig
Normal 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
6
.env.example
Normal 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
10
.gitattributes
vendored
Normal 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
56
.github/workflows/ci.yml
vendored
Normal 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
56
.gitignore
vendored
|
|
@ -2,9 +2,9 @@
|
|||
composer.phar
|
||||
/vendor/
|
||||
|
||||
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
|
||||
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
|
||||
# composer.lock
|
||||
# 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
|
||||
|
|
@ -85,3 +85,53 @@ fabric.properties
|
|||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
|
||||
# --- Additional ignores (Phred project) ---
|
||||
|
||||
# Environment files (keep .env.example tracked)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE/editor folders
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor swap/backup files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Tool caches and build artifacts
|
||||
.php-cs-fixer.cache
|
||||
.phpunit.result.cache
|
||||
.cache/
|
||||
coverage/
|
||||
.coverage/
|
||||
build/
|
||||
var/
|
||||
tmp/
|
||||
|
||||
# Scaffolding output created during local development of this template
|
||||
# These are part of generated apps, not this template repo
|
||||
/public/
|
||||
/bootstrap/
|
||||
/routes/
|
||||
/resources/
|
||||
/modules/
|
||||
/storage/*
|
||||
!/storage/.gitkeep
|
||||
/config/
|
||||
/console/
|
||||
|
||||
# Codeception outputs
|
||||
tests/_output/
|
||||
tests/_support/_generated/
|
||||
|
||||
/.env
|
||||
/.phpunit.cache
|
||||
/.php-cs-fixer.cache
|
||||
|
|
|
|||
163
MILESTONES.md
Normal file
163
MILESTONES.md
Normal 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 developer‑facing 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 standards‑compliant 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 full‑site rendering.
|
||||
* Rendering works via RendererInterface and can be swapped (e.g., Eyrie → Twig demo) with only configuration/provider changes.
|
||||
## M7 — Modules (Django‑style app structure)
|
||||
* Tasks:
|
||||
* Define module filesystem layout (Nested Controllers/Views/Services/Models/Templates/Routes/Tests).
|
||||
* Module loader: auto‑register 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; JWT‑protected route example works.
|
||||
## M11 — Logging, HTTP client, and filesystem
|
||||
* Tasks:
|
||||
* Monolog setup with handlers and processors (request ID, memory, timing).
|
||||
* Guzzle PSR‑18 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.
|
||||
* Pre‑commit 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/Last‑Modified 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, PHP‑FPM + 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
|
||||
* M0–M4 are critical path for the HTTP core and should be completed sequentially.
|
||||
* M5–M8 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
124
README.md
|
|
@ -1,3 +1,125 @@
|
|||
# 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
51
bin/phred
Normal 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
86
composer.json
Normal 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
8
phpstan.neon.dist
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
parameters:
|
||||
level: 5
|
||||
paths:
|
||||
- src
|
||||
checkGenericClassInNonGenericObjectType: false
|
||||
checkMissingIterableValueType: false
|
||||
checkUninitializedProperties: true
|
||||
inferPrivatePropertyTypeFromConstructor: true
|
||||
3
phred
Executable file
3
phred
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
require __DIR__ . '/bin/phred';
|
||||
67
src/Console/Command.php
Normal file
67
src/Console/Command.php
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
30
src/Http/ApiResponseFactoryInterface.php
Normal file
30
src/Http/ApiResponseFactoryInterface.php
Normal 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;
|
||||
}
|
||||
22
src/Http/Controllers/HealthController.php
Normal file
22
src/Http/Controllers/HealthController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
99
src/Http/JsonApi/JsonApiResponseFactory.php
Normal file
99
src/Http/JsonApi/JsonApiResponseFactory.php
Normal 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
79
src/Http/Kernel.php
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/Http/Middleware/ContentNegotiationMiddleware.php
Normal file
29
src/Http/Middleware/ContentNegotiationMiddleware.php
Normal 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));
|
||||
}
|
||||
}
|
||||
61
src/Http/Middleware/DispatchMiddleware.php
Normal file
61
src/Http/Middleware/DispatchMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
93
src/Http/Middleware/ProblemDetailsMiddleware.php
Normal file
93
src/Http/Middleware/ProblemDetailsMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/Http/Middleware/RoutingMiddleware.php
Normal file
44
src/Http/Middleware/RoutingMiddleware.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/Http/Rest/RestResponseFactory.php
Normal file
47
src/Http/Rest/RestResponseFactory.php
Normal 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
39
src/Http/Router.php
Normal 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
33
src/Support/Config.php
Normal 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;
|
||||
}
|
||||
}
|
||||
83
src/commands/create_command.php
Normal file
83
src/commands/create_command.php
Normal 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
189
src/commands/install.php
Normal 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;
|
||||
}
|
||||
};
|
||||
49
tests/CommandDiscoveryTest.php
Normal file
49
tests/CommandDiscoveryTest.php
Normal 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
28
tests/HttpKernelTest.php
Normal 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
41
tests/MakeCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue