feat: implement M9 & M10 (CLI, Scaffolding, Security, JWT) and standardize middleware

- Implement full suite of 'phred' CLI generators and utility commands (M9).
- Refactor scaffolding logic to use external stubs in 'src/stubs'.
- Add security hardening via SecureHeaders, Csrf, and CORS middleware (M10).
- Implement JWT token issuance and validation service with lcobucci/jwt.
- Integrate 'getphred/flagpole' for feature flag support.
- Introduce abstract 'Middleware' base class for standardized PSR-15 implementation.
- Add robust driver validation to OrmServiceProvider.
- Fix JwtTokenService claims access and validation constraints.
- Update MILESTONES.md status.
This commit is contained in:
Funky Waddle 2025-12-22 15:52:41 -06:00
parent f19054cfdb
commit c845868f41
44 changed files with 1315 additions and 246 deletions

View file

@ -86,32 +86,33 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s
* ~~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>: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.
## ~~M9 — CLI (phred) and scaffolding~~
* ~~Tasks:~~
* ~~Implement Symfony Console app in `bin/phred`.~~ ✓
* ~~Generators: `create:<module>:controller`, `create:<module>:model`, `create:<module>:migration`, `create:<module>:seed`, `create:<module>:test`, `create:<module>:view`.~~ ✓
* ~~Utility commands: `test[:<module>]`, `run`, `db:backup`, `db:restore`.~~ ✓
* ~~Acceptance:~~
* ~~Commands generate files with correct namespaces/paths and pass basic smoke tests.~~
## ~~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.
* Standardize all core service providers with robust driver validation (similar to OrmServiceProvider).
* 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).
* Add simple validation layer using `Phred\Http\Middleware\Middleware` base.
* Pagination helpers (links/meta), REST and JSON:API compatible outputs.
* URL extension negotiation: add XML support
* Provide `XmlResponseFactory` (or encoder) and integrate with negotiation.
@ -159,6 +160,7 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s
## M18 — Examples and starter template
* Tasks:
* Create `examples/blog` module showcasing controllers, views, templates, ORM, auth, pagination, and both API formats.
* Ensure examples use the external stubs and module-specific CLI command conventions.
* Provide `composer create-project` skeleton template instructions.
* Acceptance:
* New users can scaffold a working app in minutes following README.

View file

@ -26,11 +26,40 @@ namespace {
// Discover core commands bundled with Phred (moved under src/commands)
$coreDir = dirname(__DIR__) . '/src/commands';
$generators = [
'create:controller',
'create:view',
'create:model',
'create:migration',
'create:seed',
'create:test'
];
if (is_dir($coreDir)) {
foreach (glob($coreDir . '/*.php') as $file) {
/** @var \Phred\Console\Command $cmd */
$cmd = require $file;
if ($cmd instanceof \Phred\Console\Command) {
$app->add($cmd->toSymfony());
// If it's a generator, also register module-specific versions
if (in_array($cmd->getName(), $generators, true)) {
$modulesDir = getcwd() . '/modules';
if (is_dir($modulesDir)) {
foreach (scandir($modulesDir) as $module) {
if ($module === '.' || $module === '..' || !is_dir($modulesDir . '/' . $module)) {
continue;
}
// Create a module-specific command name: create:blog:controller
$moduleCmdName = str_replace('create:', 'create:' . strtolower($module) . ':', $cmd->getName());
// We need a fresh instance for each command name to avoid overwriting Symfony's command registry
$moduleCmd = require $file;
$moduleCmd->setName($moduleCmdName);
$app->add($moduleCmd->toSymfony());
}
}
}
}
}
}

View file

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

View file

@ -21,6 +21,7 @@ abstract class Command
protected array $options = [];
public function getName(): string { return $this->command; }
public function setName(string $name): void { $this->command = $name; }
public function getDescription(): string { return $this->description; }
/** @return array<string,array> */
public function getOptions(): array { return $this->options; }

View file

@ -7,9 +7,19 @@ use Phred\Flags\Contracts\FeatureFlagClientInterface;
final class FlagpoleClient implements FeatureFlagClientInterface
{
private \Flagpole\FeatureManager $manager;
public function __construct()
{
// For now, use an empty repository or load from config if needed.
// Milestone M10 calls for a default adapter using Flagpole.
$this->manager = new \Flagpole\FeatureManager(
new \Flagpole\Repository\InMemoryFlagRepository()
);
}
public function isEnabled(string $flagKey, array $context = []): bool
{
// default to false in placeholder implementation
return false;
return $this->manager->enabled($flagKey, new \Flagpole\Context($context));
}
}

View file

@ -42,12 +42,32 @@ final class Kernel
public function handle(ServerRequest $request): ResponseInterface
{
$psr17 = new Psr17Factory();
$config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class);
// CORS
$corsSettings = new \Neomerx\Cors\Strategies\Settings();
$corsSettings->init(
parse_url((string)getenv('APP_URL'), PHP_URL_SCHEME) ?: 'http',
parse_url((string)getenv('APP_URL'), PHP_URL_HOST) ?: 'localhost',
(int)parse_url((string)getenv('APP_URL'), PHP_URL_PORT) ?: 80
);
$corsSettings->setAllowedOrigins($config->get('cors.origin', ['*']));
$corsSettings->setAllowedMethods($config->get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']));
$corsSettings->setAllowedHeaders($config->get('cors.headers.allow', ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With']));
$corsSettings->enableAllOriginsAllowed();
$corsSettings->enableAllMethodsAllowed();
$corsSettings->enableAllHeadersAllowed();
$middleware = [
// Security headers
new Middleware\Security\SecureHeadersMiddleware($config),
// CORS
new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)),
new Middleware\ProblemDetailsMiddleware(
\Phred\Support\Config::get('APP_DEBUG', 'false') === 'true',
$config->get('APP_DEBUG', 'false') === 'true',
null,
null,
filter_var(\Phred\Support\Config::get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
),
// Perform extension-based content negotiation hinting before standard negotiation
new Middleware\UrlExtensionNegotiationMiddleware(),

View file

@ -13,7 +13,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class ContentNegotiationMiddleware implements MiddlewareInterface
class ContentNegotiationMiddleware extends Middleware
{
public function __construct(
private readonly ?ConfigInterface $config = null,

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
abstract class Middleware implements MiddlewareInterface
{
abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface;
protected function json(array $data, int $status = 200): ResponseInterface
{
$response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']);
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES));
return $response;
}
}

View file

@ -21,7 +21,7 @@ use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
class ProblemDetailsMiddleware implements MiddlewareInterface
class ProblemDetailsMiddleware extends Middleware
{
public function __construct(
private readonly bool $debug = false,

View file

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

View file

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

View file

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

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Security\Contracts\TokenServiceInterface;
use Phred\Security\Jwt\JwtTokenService;
use Phred\Flags\Contracts\FeatureFlagClientInterface;
use Phred\Flags\FlagpoleClient;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class SecurityServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$builder->addDefinitions([
TokenServiceInterface::class => \DI\autowire(JwtTokenService::class),
]);
}
public function boot(Container $container): void {}
}

View file

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

View file

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

View file

@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
use Phred\Console\Command;
use Symfony\Component\Console\Input\InputInterface as Input;
use Symfony\Component\Console\Output\OutputInterface as Output;
return new class extends Command {
protected string $command = 'create:command';
protected string $description = 'Scaffold a new 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;
}
};

View file

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

View file

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

View file

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

View file

@ -121,8 +121,8 @@ return new class extends Command {
if (method_exists($input, 'getOptions')) {
$opts = $input->getOptions();
if (is_array($opts)) {
$updateComposer = $updateComposer || !empty($opts['--update-composer']) || !empty($opts['update-composer']);
$noDump = $noDump || !empty($opts['--no-dump']) || !empty($opts['no-dump']);
$updateComposer = !empty($opts['--update-composer']) || !empty($opts['update-composer']);
$noDump = !empty($opts['--no-dump']) || !empty($opts['no-dump']);
}
}
// Reflection fallback to read raw parameters from ArrayInput
@ -225,40 +225,12 @@ return new class extends Command {
{
$providerClass = $name . 'ServiceProvider';
$providerNs = "Project\\Modules\\$name\\Providers";
$providerCode = <<<'PHP'
<?php
declare(strict_types=1);
namespace $providerNs;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
use Phred\Http\Routing\RouteRegistry;
use Phred\Http\Router;
final class $providerClass implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
// Bind repository interfaces to driver-specific implementations here if desired.
}
public function boot(Container $container): void
{
// Example route
RouteRegistry::add(static function ($collector, Router $router): void {
$router->get('/$name', static function () {
return (new \Nyholm\Psr7\Factory\Psr17Factory())
->createResponse(200)
->withHeader('Content-Type', 'text/plain')
->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('$name module ready'));
});
});
}
}
PHP;
$stub = file_get_contents(dirname(__DIR__) . '/stubs/module/provider.stub');
$providerCode = strtr($stub, [
'{{namespace}}' => $providerNs,
'{{class}}' => $providerClass,
'{{name}}' => $name,
]);
file_put_contents($moduleRoot . '/Providers/' . $providerClass . '.php', $providerCode);
}
@ -271,45 +243,19 @@ PHP;
private function writeViewControllerTemplateStubs(string $moduleRoot, string $name): void
{
$viewNs = "Project\\Modules\\$name\\Views";
$viewCode = <<<'PHP'
<?php
declare(strict_types=1);
namespace $viewNs;
use Phred\Mvc\View;
final class HomeView extends View
{
protected string $template = 'home';
}
PHP;
$viewStub = file_get_contents(dirname(__DIR__) . '/stubs/module/view.stub');
$viewCode = strtr($viewStub, [
'{{namespace}}' => $viewNs,
]);
file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode);
$ctrlNs = "Project\\Modules\\$name\\Controllers";
$ctrlUsesViewNs = "Project\\Modules\\$name\\Views\\HomeView";
$ctrlTemplate = <<<'PHP'
<?php
declare(strict_types=1);
namespace __CTRL_NS__;
use Phred\Mvc\ViewController;
use Psr\Http\Message\ServerRequestInterface as Request;
use __CTRL_VIEW_NS__;
final class HomeController extends ViewController
{
public function __invoke(Request $request, HomeView $view)
{
return $this->renderView($view, ['title' => '__MOD_NAME__']);
}
}
PHP;
$ctrlCode = strtr($ctrlTemplate, [
'__CTRL_NS__' => $ctrlNs,
'__CTRL_VIEW_NS__' => $ctrlUsesViewNs,
'__MOD_NAME__' => $name,
$ctrlStub = file_get_contents(dirname(__DIR__) . '/stubs/module/controller.stub');
$ctrlCode = strtr($ctrlStub, [
'{{namespace}}' => $ctrlNs,
'{{viewNamespace}}' => $ctrlUsesViewNs,
'{{moduleName}}' => $name,
]);
file_put_contents($moduleRoot . '/Controllers/HomeController.php', $ctrlCode);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,73 +55,14 @@ return new class extends Command {
}
// Files to scaffold
$stubDir = dirname(__DIR__) . '/stubs/install';
$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,
'public/index.php' => file_get_contents($stubDir . '/public_index.stub'),
'bootstrap/app.php' => file_get_contents($stubDir . '/bootstrap_app.stub'),
'config/app.php' => file_get_contents($stubDir . '/config_app.stub'),
'routes/web.php' => file_get_contents($stubDir . '/routes_web.stub'),
'routes/api.php' => file_get_contents($stubDir . '/routes_api.stub'),
'.env.example' => file_get_contents($stubDir . '/env_example.stub'),
];
foreach ($files as $relative => $contents) {

45
src/commands/run.php Normal file
View file

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

38
src/commands/test.php Normal file
View file

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

15
src/stubs/controller.stub Normal file
View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
use Phred\Mvc\ViewController;
use Psr\Http\Message\ServerRequestInterface as Request;
{{useView}}
final class {{class}} extends ViewController
{
public function __invoke({{params}})
{
{{body}}
}
}

View file

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

View file

@ -0,0 +1,9 @@
<?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',
];

View file

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

View file

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

View file

@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
// Define API routes here
// Example: $router->get('/health', [HealthController::class, '__invoke']);

View file

@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
// Define web routes here
// Example (FastRoute style):
// $router->get('/', [HomeController::class, '__invoke']);

14
src/stubs/migration.stub Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return new class {
public function up(): void
{
// Add migration logic here
}
public function down(): void
{
// Add rollback logic here
}
};

9
src/stubs/model.stub Normal file
View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
final class {{class}}
{
// Pure PHP domain model
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
use Phred\Mvc\ViewController;
use Psr\Http\Message\ServerRequestInterface as Request;
use {{viewNamespace}};
final class HomeController extends ViewController
{
public function __invoke(Request $request, HomeView $view)
{
return $this->renderView($view, ['title' => '{{moduleName}}']);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
use Phred\Http\Routing\RouteRegistry;
use Phred\Http\Router;
final class {{class}} implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
// Bind repository interfaces to driver-specific implementations here if desired.
}
public function boot(Container $container): void
{
// Example route
RouteRegistry::add(static function ($collector, Router $router): void {
$router->get('/{{name}}', static function () {
return (new \Nyholm\Psr7\Factory\Psr17Factory())
->createResponse(200)
->withHeader('Content-Type', 'text/plain')
->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('{{name}} module ready'));
});
});
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
use Phred\Mvc\View;
final class HomeView extends View
{
protected string $template = 'home';
}

14
src/stubs/seed.stub Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return new class {
public function run(): void
{
// Add seeding logic here
}
public function rollback(): void
{
// Add rollback logic here
}
};

14
src/stubs/test.stub Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
use PHPUnit\Framework\TestCase;
final class {{class}} extends TestCase
{
public function test_example(): void
{
$this->assertTrue(true);
}
}

11
src/stubs/view.stub Normal file
View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace {{namespace}};
use Phred\Mvc\View;
final class {{class}} extends View
{
protected string $template = '{{template}}';
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Phred\Tests\Feature;
use PHPUnit\Framework\TestCase;
use Phred\Http\Kernel;
use Nyholm\Psr7\ServerRequest;
class SecurityTest extends TestCase
{
public function test_secure_headers_are_present(): void
{
$kernel = new Kernel();
$request = new ServerRequest('GET', '/_phred/health');
$response = $kernel->handle($request);
$this->assertEquals('nosniff', $response->getHeaderLine('X-Content-Type-Options'));
$this->assertEquals('SAMEORIGIN', $response->getHeaderLine('X-Frame-Options'));
}
public function test_cors_headers_are_present(): void
{
$kernel = new Kernel();
// Preflight request
$request = new ServerRequest('OPTIONS', '/_phred/health');
$request = $request->withHeader('Origin', 'http://example.com')
->withHeader('Access-Control-Request-Method', 'GET');
$response = $kernel->handle($request);
$this->assertEquals('http://example.com', $response->getHeaderLine('Access-Control-Allow-Origin'));
}
}