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
+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);