diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3fb019..dd250c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,12 @@ - Raised the static analysis baseline to PHPStan level `8` with PHP `8.0` target settings (#532) - Kept Rector aligned to the PHP 8 baseline and applied bounded PHP 8-safe modernization updates (#532) - Simplified the standard framework file banner across core sources, templates, and affected tests (#532) +- Made Twig an optional renderer dependency so the framework can retain PHP 8.0 support without requiring vulnerable Twig releases (#532) +- Upgraded the PHP 8.0-compatible runtime lines for `psr/log`, `php-curl-class`, `php-debugbar`, `phpmailer`, `voku/html-min`, `swagger-ui`, and `swagger-php` (#532) ### Notes - OpenAPI metadata remains on Doctrine-style annotations for now because the currently used `swagger-php` attribute scanning path requires PHP `8.1+`; `doctrine/annotations` is intentionally retained on the PHP `8.0` baseline (#532) +- Twig rendering requires a separately installed, security-supported Twig release; Twig `^3.27` requires PHP 8.1 or later, while the default HTML renderer remains available on PHP 8.0 (#532) ## [3.0.0] - 2026-05-28 diff --git a/README.md b/README.md index 5e5283b5..73d58fee 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,19 @@

Features

Quantum +

Requirements

+ +- PHP 8.0 or later + +The HTML renderer is available by default and does not require Twig. Twig rendering remains supported as an optional +integration. Install a currently supported Twig release separately before selecting the `twig` renderer: + +```bash +composer require twig/twig:^3.27 +``` + +Twig 3.27 and later require PHP 8.1. Applications that must remain on PHP 8.0 should use the default HTML renderer. +

Installation

1. Create new project

diff --git a/composer.json b/composer.json index 424abeff..4a848833 100644 --- a/composer.json +++ b/composer.json @@ -39,18 +39,17 @@ "symfony/console": "^5.4", "vlucas/phpdotenv": "^5.0", "dflydev/dot-access-data": "^3.0", - "php-debugbar/php-debugbar": "^1.8", - "phpmailer/phpmailer": "^6.0", - "twig/twig": ">=3.0 <3.26", - "php-curl-class/php-curl-class": "^11.0", - "psr/log": "^1.1", + "php-debugbar/php-debugbar": "^2.2", + "phpmailer/phpmailer": "^7.1", + "php-curl-class/php-curl-class": "^13.0", + "psr/log": "^2.0", "rakibtg/sleekdb": "^2.13", - "swagger-api/swagger-ui": "^4.14", - "zircote/swagger-php": "~4.4", + "swagger-api/swagger-ui": "^5.32", + "zircote/swagger-php": "^5.8", "psr/simple-cache": "^1.0", "vaibhavpandeyvpz/phemail": "^1.1", "doctrine/annotations": "^2.0", - "voku/html-min": "^4.5", + "voku/html-min": "^5.0", "league/commonmark": "^2.0", "ezyang/htmlpurifier": "^4.18", "povils/figlet": "^0.1.0", @@ -69,7 +68,8 @@ "ext-dom": "Required for XML/HTML processing", "ext-zip": "Required for ZIP file handling", "ext-memcached": "Required for Memcached support", - "ext-redis": "Required for Redis support" + "ext-redis": "Required for Redis support", + "twig/twig": "Required for the optional Twig renderer; secure Twig ^3.27 releases require PHP 8.1 or later" }, "autoload": { "psr-4": { @@ -90,10 +90,5 @@ "test": "vendor/bin/phpunit --stderr --coverage-clover coverage.xml" }, "minimum-stability": "stable", - "prefer-stable": true, - "config": { - "audit": { - "block-insecure": false - } - } + "prefer-stable": true } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d0b99094..6cef9766 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,6 +2,9 @@ parameters: phpVersion: 80000 level: 8 + scanFiles: + - stubs/Twig.stub + paths: - src diff --git a/src/Logger/Adapters/MessageAdapter.php b/src/Logger/Adapters/MessageAdapter.php index 53ca913c..6c22da04 100644 --- a/src/Logger/Adapters/MessageAdapter.php +++ b/src/Logger/Adapters/MessageAdapter.php @@ -12,6 +12,7 @@ use Quantum\Logger\Contracts\ReportableInterface; use Quantum\Debugger\Debugger; +use Stringable; /** * Class MessageAdapter @@ -19,7 +20,7 @@ */ class MessageAdapter implements ReportableInterface { - public function report(string $level, string $message, ?array $context = []): void + public function report(string $level, string|Stringable $message, ?array $context = []): void { $tab = $context['tab'] ?? Debugger::MESSAGES; diff --git a/src/Logger/Contracts/ReportableInterface.php b/src/Logger/Contracts/ReportableInterface.php index a63661a2..e8ab942a 100644 --- a/src/Logger/Contracts/ReportableInterface.php +++ b/src/Logger/Contracts/ReportableInterface.php @@ -10,6 +10,8 @@ namespace Quantum\Logger\Contracts; +use Stringable; + /** * Interface ReportableInterface * @package Quantum\Logger @@ -20,6 +22,6 @@ interface ReportableInterface * Reports a message * @param array|null $context */ - public function report(string $level, string $message, ?array $context = []): void; + public function report(string $level, string|Stringable $message, ?array $context = []): void; } diff --git a/src/Logger/Traits/LoggerTrait.php b/src/Logger/Traits/LoggerTrait.php index d69c0289..cf3489bc 100644 --- a/src/Logger/Traits/LoggerTrait.php +++ b/src/Logger/Traits/LoggerTrait.php @@ -12,6 +12,7 @@ use Quantum\Logger\Exceptions\LoggerException; use Quantum\Storage\FileSystem; +use Stringable; /** * Trait LoggerTrait @@ -33,14 +34,14 @@ abstract protected function initialize(array $params): void; * Reports a log message * @param array|null $context */ - public function report(string $level, string $message, ?array $context = []): void + public function report(string $level, string|Stringable $message, ?array $context = []): void { $this->fs->append($this->logFile, $this->formatMessage($level, $message, $context)); } /** * Formats the log message - * @param string|array $message + * @param string|Stringable|array $message * @param array|null $context */ protected function formatMessage(string $level, $message, ?array $context = []): string @@ -49,7 +50,7 @@ protected function formatMessage(string $level, $message, ?array $context = []): '[%s] %s: %s%s', date('Y-m-d H:i:s'), ucfirst($level), - is_array($message) ? json_encode($message, JSON_PRETTY_PRINT) : $message, + is_array($message) ? json_encode($message, JSON_PRETTY_PRINT) : (string) $message, isset($context['trace']) ? (PHP_EOL . $context['trace'] . PHP_EOL) : PHP_EOL ); } diff --git a/src/Renderer/Adapters/TwigAdapter.php b/src/Renderer/Adapters/TwigAdapter.php index ee2a46f0..b188d90c 100644 --- a/src/Renderer/Adapters/TwigAdapter.php +++ b/src/Renderer/Adapters/TwigAdapter.php @@ -44,6 +44,10 @@ class TwigAdapter implements TemplateRendererInterface */ public function __construct(?array $configs = []) { + if (!class_exists(Environment::class)) { + throw RendererException::twigNotInstalled(); + } + $this->configs = $configs; $this->fs = FileSystemFactory::get(); diff --git a/src/Renderer/Exceptions/RendererException.php b/src/Renderer/Exceptions/RendererException.php index 2f8b06e2..c6435bc6 100644 --- a/src/Renderer/Exceptions/RendererException.php +++ b/src/Renderer/Exceptions/RendererException.php @@ -19,4 +19,12 @@ */ class RendererException extends BaseException { + public static function twigNotInstalled(): self + { + return new self( + 'The Twig renderer requires the optional `twig/twig` package. ' . + 'Install a security-supported release separately; Twig ^3.27 requires PHP 8.1 or later: ' . + '`composer require twig/twig:^3.27`.' + ); + } } diff --git a/stubs/Twig.stub b/stubs/Twig.stub new file mode 100644 index 00000000..40f7855d --- /dev/null +++ b/stubs/Twig.stub @@ -0,0 +1,58 @@ + $options + */ + public function __construct(FilesystemLoader $loader, array $options = []) + { + } + + /** + * @param array $context + */ + public function render(string $name, array $context = []): string + { + } + + public function addFunction(TwigFunction $function): void + { + } +} + +class TwigFunction +{ + public function __construct(string $name, callable $callable) + { + } +} + +namespace Twig\Loader; + +class FilesystemLoader +{ + public function __construct(string $path) + { + } +} + +namespace Twig\Error; + +use Exception; + +class LoaderError extends Exception +{ +} + +class RuntimeError extends Exception +{ +} + +class SyntaxError extends Exception +{ +} diff --git a/tests/Unit/Renderer/Adapters/TwigAdapterTest.php b/tests/Unit/Renderer/Adapters/TwigAdapterTest.php index 0cb032e0..6811b7a1 100644 --- a/tests/Unit/Renderer/Adapters/TwigAdapterTest.php +++ b/tests/Unit/Renderer/Adapters/TwigAdapterTest.php @@ -3,6 +3,7 @@ namespace Quantum\Tests\Unit\Renderer\Adapters; use Quantum\Renderer\Adapters\TwigAdapter; +use Quantum\Renderer\Exceptions\RendererException; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\MatchedRoute; use Quantum\Router\Route; @@ -27,8 +28,12 @@ public function setUp(): void request()->setMatchedRoute($matchedRoute); } - public function testHtmlAdapterRenderView(): void + public function testTwigAdapterRenderView(): void { + if (!class_exists('Twig\Environment')) { + $this->markTestSkipped('The optional twig/twig package is not installed.'); + } + $adapter = new TwigAdapter(); $output = $adapter->render('index.twig', ['name' => 'Tester']); @@ -38,4 +43,19 @@ public function testHtmlAdapterRenderView(): void $this->assertSame('

Hello Tester, this is rendered twig view

', $output); } + public function testTwigAdapterThrowsActionableExceptionWhenTwigIsMissing(): void + { + if (class_exists('Twig\Environment')) { + $this->markTestSkipped('The optional twig/twig package is installed.'); + } + + $this->expectException(RendererException::class); + $this->expectExceptionMessage( + 'The Twig renderer requires the optional `twig/twig` package. ' . + 'Install a security-supported release separately; Twig ^3.27 requires PHP 8.1 or later: ' . + '`composer require twig/twig:^3.27`.' + ); + + new TwigAdapter(); + } } diff --git a/tests/Unit/Renderer/Factories/RendererFactoryTest.php b/tests/Unit/Renderer/Factories/RendererFactoryTest.php index 3e825118..19b1d7da 100644 --- a/tests/Unit/Renderer/Factories/RendererFactoryTest.php +++ b/tests/Unit/Renderer/Factories/RendererFactoryTest.php @@ -48,6 +48,10 @@ public function testRendererFactoryGetHtmlAdapter(): void public function testRendererFactoryTwigAdapter(): void { + if (!class_exists('Twig\Environment')) { + $this->markTestSkipped('The optional twig/twig package is not installed.'); + } + $renderer = RendererFactory::get(RendererType::TWIG); $this->assertInstanceOf(TemplateRendererInterface::class, $renderer->getAdapter()); diff --git a/tests/Unit/Renderer/RendererTest.php b/tests/Unit/Renderer/RendererTest.php index f0dee936..6b21186b 100644 --- a/tests/Unit/Renderer/RendererTest.php +++ b/tests/Unit/Renderer/RendererTest.php @@ -42,6 +42,10 @@ public function testRendererGetHtmlAdapter(): void public function testRendererGetTwigAdapter(): void { + if (!class_exists('Twig\Environment')) { + $this->markTestSkipped('The optional twig/twig package is not installed.'); + } + $renderer = new Renderer(new TwigAdapter()); $this->assertInstanceOf(TwigAdapter::class, $renderer->getAdapter()); diff --git a/tests/Unit/View/ViewTest.php b/tests/Unit/View/ViewTest.php index ff06859b..b252e2cb 100644 --- a/tests/Unit/View/ViewTest.php +++ b/tests/Unit/View/ViewTest.php @@ -170,6 +170,10 @@ public function testRenderPartial(): void public function testRenderViewWithTwig(): void { + if (!class_exists('Twig\Environment')) { + $this->markTestSkipped('The optional twig/twig package is not installed.'); + } + $factory = Di::get(ViewFactory::class); $this->setPrivateProperty($factory, 'instance', null);