• Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes • Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes • Implement create:module as a console Command (extends Phred\Console\Command): ◦ Args: name, prefix; Flags: --update-composer, --no-dump ◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput ◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep ◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders) ◦ Appends guarded include snippet to routes/web.php ◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump ◦ Prevents providers.php corruption via name validation and existence checks • Add URL extension negotiation middleware tweaks: ◦ Only set Accept for .json (and future .xml), never for none/php ◦ Never override existing Accept header • Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract • Add tests: ◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping ◦ ProviderRouteTest for provider-contributed route ◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior ◦ MvcViewTest validates transformData+render • Fix config/providers.php syntax and add comment placeholder for modules • Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow • Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
330 lines
15 KiB
Markdown
330 lines
15 KiB
Markdown
# Phred
|
|
|
|
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 (with content negotiation)
|
|
* 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)
|
|
* Or negotiate per request using the `Accept` header:
|
|
* `Accept: application/vnd.api+json` forces JSON:API for that request
|
|
* 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 (M5)
|
|
* Core depends on Phred contracts and PSRs; concrete implementations are provided by Service Providers.
|
|
* Providers implement `Phred\Support\Contracts\ServiceProviderInterface` with `register(ContainerBuilder)` and `boot(Container)` methods.
|
|
* Providers are loaded in deterministic order: core → app → modules, configured in `config/providers.php`.
|
|
* Swap packages by changing `.env` / `config/app.php` drivers 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\Contracts\RendererInterface`
|
|
* `Orm\Contracts\ConnectionInterface` (or repositories in future milestones)
|
|
* `Flags\Contracts\FeatureFlagClientInterface`
|
|
* `Testing\Contracts\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`
|
|
* URL extension negotiation (optional)
|
|
* Opt-in middleware that parses a trailing URL extension and hints content negotiation.
|
|
* Enable via env: `URL_EXTENSION_NEGOTIATION=true` (default true)
|
|
* Control allowed extensions via: `URL_EXTENSION_WHITELIST="json|php|none"`
|
|
* Defaults to `json|php|none`. XML support will be added in M12.
|
|
* Examples:
|
|
* `/users/1.json` → JSON response
|
|
* `/users/1` or `/users/1.php` → HTML (views) by convention
|
|
* Extensible: future formats can be added with a factory and whitelisting.
|
|
* CONTROLLERS
|
|
* Invokable controllers (Actions),
|
|
* Single router call per controller,
|
|
* `public function __invoke(Request $request)` method entry point on controller class,
|
|
* Response helpers via dependency injection:
|
|
* Inject `Phred\Http\Contracts\ApiResponseFactoryInterface` to build responses consistently across formats.
|
|
* The factory is negotiated per request (env default or `Accept` header) and sets appropriate `Content-Type`.
|
|
* Common methods: `ok(array $data)`, `created(array $data, ?string $location)`, `noContent()`, `error(int $status, string $title, ?string $detail, array $extra = [])`.
|
|
* Example:
|
|
```php
|
|
use Phred\Http\Contracts\ApiResponseFactoryInterface as Responses;
|
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
|
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
|
|
|
|
final class ExampleController {
|
|
public function __construct(private Responses $responses) {}
|
|
public function __invoke(Request $request) {
|
|
$format = $request->getAttribute(Negotiation::ATTR_API_FORMAT, 'rest');
|
|
return $this->responses->ok(['format' => $format]);
|
|
}
|
|
}
|
|
```
|
|
* MVC controller bases (M6)
|
|
* For API endpoints inside modules, extend `Phred\Mvc\APIController` and use response helpers:
|
|
```php
|
|
use Phred\Mvc\APIController;
|
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
|
|
|
final class UserShowController extends APIController {
|
|
public function __invoke(Request $request) {
|
|
$user = ['id' => 123, 'name' => 'Ada'];
|
|
return $this->ok(['user' => $user]);
|
|
}
|
|
}
|
|
```
|
|
* For HTML endpoints inside modules, extend `Phred\Mvc\ViewController` and delegate to a module `View`:
|
|
```php
|
|
use Phred\Mvc\ViewController;
|
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
|
|
|
final class HomePageController extends ViewController {
|
|
public function __invoke(Request $request, HomePageView $view) {
|
|
// domain data (normally from a Service)
|
|
$data = ['title' => 'Welcome', 'name' => 'world'];
|
|
// Use the view's default template by omitting the template param
|
|
return $this->renderView($view, $data);
|
|
// Or override template explicitly via 3rd param:
|
|
// return $this->renderView($view, $data, 'home');
|
|
}
|
|
}
|
|
```
|
|
* VIEWS
|
|
* Classes for data manipulation/preparation before rendering Templates,
|
|
* `$this->render(<template_name>, <data_array>);` to render a template.
|
|
* Base class (M6): extend `Phred\Mvc\View` in your module and optionally override `transformData()`:
|
|
```php
|
|
use Phred\Mvc\View;
|
|
|
|
final class HomePageView extends View {
|
|
protected string $template = 'home'; // default template
|
|
protected function transformData(array $data): array {
|
|
$data['upper'] = strtoupper($data['name'] ?? '');
|
|
return $data;
|
|
}
|
|
}
|
|
```
|
|
* With the default Eyrie renderer, a simple template string `"<h1>Hello {{upper}}</h1>"` would produce `<h1>Hello WORLD</h1>`.
|
|
* Template selection conventions (M6)
|
|
* Controllers always call `renderView($view, $data, ?$templateOverride)`:
|
|
- Use default template: `renderView($view, $data)` (no third parameter)
|
|
- Override template explicitly: `renderView($view, $data, 'template_name')`
|
|
* Views own default template selection and presentation logic:
|
|
- Declare `protected string $template = 'default_name';` or override `defaultTemplate()` for dynamic selection
|
|
- The `View` decides which template to use when the controller does not pass an override
|
|
* Rationale: keeps template/presentation decisions in the View layer; controllers only make explicit overrides when necessary (flags, A/B tests, special flows).
|
|
* SERVICES
|
|
* for business logic.
|
|
* MODULES (M7)
|
|
* Django-style: all user Controllers, Views, Templates, Services, etc. live inside modules.
|
|
* Suggested module layout (ORM-agnostic):
|
|
- `modules/<Module>/Controllers/`
|
|
- `modules/<Module>/Views/`
|
|
- `modules/<Module>/Templates/`
|
|
- `modules/<Module>/Services/`
|
|
- `modules/<Module>/Models/` (domain models, ORM-neutral)
|
|
- `modules/<Module>/Repositories/` (interfaces consumed by services)
|
|
- `modules/<Module>/Persistence/Pairity/` (DAOs, DTOs, mappers, repo implementations)
|
|
- `modules/<Module>/Persistence/Eloquent/` (Eloquent models, repo implementations)
|
|
- `modules/<Module>/Database/Migrations/` (canonical migrations for the module)
|
|
- `modules/<Module>/Routes/web.php` and `api.php`
|
|
- `modules/<Module>/Providers/*ServiceProvider.php`
|
|
* Swap ORM driver (`ORM_DRIVER=pairity|eloquent`) without changing services/controllers: providers bind repository interfaces to driver-specific implementations. Migrations remain a single canonical set per module.
|
|
* Creating a Module
|
|
- With prompt (interactive):
|
|
```bash
|
|
php phred create:module Blog
|
|
# Enter URL prefix for module 'Blog' [/blog]:
|
|
```
|
|
- The command will scaffold `modules/Blog/*`, register the provider in `config/providers.php` (modules section), and append an inclusion snippet to `routes/web.php` mounting the module at `/blog`.
|
|
- Without prompt (explicit prefix argument):
|
|
```bash
|
|
php phred create:module Blog /blog
|
|
# or
|
|
php phred create:module Blog --prefix=/blog
|
|
```
|
|
- After creation, add PSR-4 to `composer.json` and dump autoload:
|
|
```json
|
|
{
|
|
"autoload": {
|
|
"psr-4": {
|
|
"Project\\\\Modules\\\\Blog\\\\": "modules/Blog/"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
```bash
|
|
composer dump-autoload
|
|
```
|
|
- Route inclusion pattern in `routes/web.php` (added automatically):
|
|
```php
|
|
use Phred\\Http\\Routing\\RouteGroups;
|
|
use Phred\\Http\\Router;
|
|
|
|
// Module 'Blog' mounted at '/blog'
|
|
RouteGroups::include($router, '/blog', function (Router $router) {
|
|
/** @noinspection PhpIncludeInspection */
|
|
(static function ($router) { require __DIR__ . '/../modules/Blog/Routes/web.php'; })($router);
|
|
});
|
|
```
|
|
- Define module-local routes relative to the module in `modules/Blog/Routes/web.php`:
|
|
```php
|
|
use Phred\\Http\\Router;
|
|
use Project\\Modules\\Blog\\Controllers as C;
|
|
|
|
$router->get('/', C\\HomeController::class); // -> /blog
|
|
$router->get('/posts', C\\PostIndexController::class); // -> /blog/posts
|
|
```
|
|
* SERVICE PROVIDERS (M5)
|
|
* for dependency injection and runtime bootstrapping.
|
|
* Configure providers in `config/providers.php`:
|
|
```php
|
|
return [
|
|
'core' => [
|
|
Phred\Providers\Core\RoutingServiceProvider::class,
|
|
Phred\Providers\Core\TemplateServiceProvider::class,
|
|
Phred\Providers\Core\OrmServiceProvider::class,
|
|
Phred\Providers\Core\FlagsServiceProvider::class,
|
|
Phred\Providers\Core\TestingServiceProvider::class,
|
|
],
|
|
'app' => [
|
|
Phred\Providers\AppServiceProvider::class,
|
|
],
|
|
'modules' => [],
|
|
];
|
|
```
|
|
* Add routes from a provider using the `RouteRegistry` helper:
|
|
```php
|
|
use Phred\Http\Routing\RouteRegistry;
|
|
use Phred\Http\Router;
|
|
|
|
RouteRegistry::add(static function ($collector, Router $router): void {
|
|
$router->get('/hello', fn() => ['message' => 'Hello from a provider']);
|
|
});
|
|
```
|
|
* Select drivers via env or `config/app.php` under `drivers`:
|
|
- `TEMPLATE_DRIVER=eyrie`
|
|
- `ORM_DRIVER=pairity`
|
|
- `FLAGS_DRIVER=flagpole`
|
|
- `TEST_RUNNER=codeception`
|
|
* Defaults are provided by core providers and can be swapped by changing these keys and adding alternate providers.
|
|
* 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; }
|
|
};
|
|
```
|
|
|
|
Configuration and environment
|
|
|
|
- Phred uses `vlucas/phpdotenv` to load a `.env` file from your project root (loaded in `bootstrap/app.php`).
|
|
- Access configuration anywhere via `Phred\Support\Config::get(<key>, <default>)`.
|
|
- Precedence: environment variables > config files > provided default.
|
|
- Keys may be provided in either UPPER_SNAKE (e.g., `APP_ENV`) or dot.notation (e.g., `app.env`).
|
|
- Config files live under `config/*.php` and return arrays; dot keys are addressed as `<file>.<path>` (e.g., `app.timezone`).
|
|
|
|
Common keys
|
|
|
|
- `APP_ENV` (default from config/app.php: `local`)
|
|
- `APP_DEBUG` (`true`/`false`)
|
|
- `APP_TIMEZONE` (default `UTC`)
|
|
- `API_FORMAT` (`rest` | `jsonapi`; default `rest`)
|
|
|
|
API formats and negotiation
|
|
|
|
- Middleware `ContentNegotiationMiddleware` determines the active API format per request.
|
|
- Precedence:
|
|
1. `Accept: application/vnd.api+json` → JSON:API
|
|
2. `.env`/config `API_FORMAT` (fallback to `rest`)
|
|
- The chosen format is stored on the request as `phred.api_format` and used by the injected `ApiResponseFactoryInterface` to produce the correct response shape and `Content-Type`.
|
|
- Demo endpoint: `GET /_phred/format` responds with the active format; `GET /_phred/health` returns a simple JSON 200.
|
|
|
|
Examples
|
|
|
|
```php
|
|
use Phred\Support\Config;
|
|
|
|
$env = Config::get('APP_ENV', 'local'); // reads from env, then config/app.php, else 'local'
|
|
$tz = Config::get('app.timezone', 'UTC'); // reads nested key from config files
|
|
$fmt = strtolower(Config::get('API_FORMAT', 'rest'));
|
|
```
|