Skip to content
Merged
17 changes: 12 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,21 @@ The format is based on Keep a Changelog.
- Added `Request::expectsJson()`: true when the `Accept` header lists a JSON media type (`application/json` or a `+json` type, parameters and case ignored), or for an AJAX request that accepts any content type (#581)
- Added lifecycle events for the web and console runtime: the runtime stages declare `web.preflight`, `web.route`, `web.not_found`, `web.prepare`, `web.dispatch`, `web.send` and `console.command`, each with `.before` and `.after`, and the `Pipeline` dispatches them with the runtime context as a named `context` payload. A stage that does not run dispatches nothing, a stage that throws dispatches no `.after`, and the closing `web.send` events also follow an OPTIONS or not found response. Boot stages declare no events. `console.command.after` fires only with a Symfony application that does not exit after the command (#536, #538)
- Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533)
- Added the `is_web_app()` and `is_console_app()` helpers, which tell whether the running application is the web app or the console (#593)

### Changed
- **BREAKING:** Took the debugbar out of the app flow and out of the packages that feed it (#539): no package outside `Quantum\Debugger` references it. Each package keeps its own state, and `Debugger::render()` reads it when the toolbar renders: every tab shows the entries written with `debugbar()->addToStoreCell()`, followed by the event listeners, the matched route and the rendered view, the query log, the log messages and the mailer transport log as they are at that point. The debugger store keeps only those direct writes, and the debugbar builds no database connection or mailer the request did not use.
- Removed `InitDebuggerStage` (`Quantum\App\Stages\InitDebuggerStage` in 3.0.3) from the web boot. The debugger store creates a tab on its first write; code that wants the tabs up front can call `debugbar()->initStore()`.
- The `events` tab lists the listeners registered when the toolbar renders, so listeners added later in the request, for example in a controller, are listed too.
- `View::__construct()` no longer takes a `Debugger`: it is `(Renderer, AssetManager, ViewCache)`. Code that gets the view through `view()` or `ViewFactory` is unaffected. Added `View::getViewFile()`, the last view rendered with `render()`.
- `MessageAdapter`, the logger in debug mode, keeps its messages in memory (`getMessages()`) instead of writing them into the debugger store, and no longer reads `$context['tab']`: a message logged with a tab is listed in the `messages` tab, and `debugbar()->getStoreCell('messages')` returns only direct writes.
- `MailerInterface` gains `getTransportLog()`, implemented in `MailerTrait`: the errors of failed sends and, for SMTP in debug mode, the exchange with the server. A custom adapter that implements the interface without the trait must add it. A failed send is still logged as a warning.
- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #538, #575, #576, #579, #583, #589):
- `MessageAdapter`, the logger in debug mode, keeps its messages in memory (`getMessages()`) instead of writing them into the debugger store, and no longer reads `$context['tab']`: a message logged with a tab is listed in the `messages` tab, and `debugbar()->getStoreCell('messages')` returns only direct writes. It keeps the last `logging.message.limit` messages (default `1000`, `0` keeps every message) and drops the oldest (#593).
- `MailerInterface` gains `getTransportLog()`, implemented in `MailerTrait`: the errors of failed sends and, for SMTP in debug mode, the exchange with the server. A custom adapter that implements the interface without the trait must add it. A failed send is still logged as a warning. At the start of each send the log is cut to its last `mailer.transport_log_limit` lines (default `1000`, `0` keeps every line), so it holds at most the limit plus the lines of the send in progress (#593).
- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #538, #575, #576, #579, #583, #589, #593):
- `AppInterface` gains `boot()`. The adapter constructors only store the context, and `AppFactory` calls `boot()` right after creating the adapter, so `AppFactory::create(...)->start()` is unchanged. Code that creates an adapter directly must call `boot()` before `start()`.
- Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits: `WebAppAdapter` and `ConsoleAppAdapter` implement `AppInterface` directly and moved to `Quantum\App\Adapters\Web` and `Quantum\App\Adapters\Console`. The stages are grouped by the adapter that runs them: the boot stages both adapters run in `Quantum\App\Stages\Boot`, the others in `Quantum\App\Adapters\Web\Stages\Boot`, `...\Web\Stages\Runtime`, `...\Console\Stages\Boot` and `...\Console\Stages\Runtime`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports.
- Replaced `BootPipeline` and `BootStageInterface` with one generic `Quantum\App\Pipeline<TContext>`, which runs `StageInterface<TContext>` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface<AppContext>` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. A stage that calls `stop()` on the context ends the run; an optional closing stage, the second constructor argument, runs last, also after a stop, but not after a stage throws.
- `AppContext` keeps `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`, and carries the state of a run: `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response.
- The new `WebContext` and `ConsoleContext` extend `AppContext` with the same base directory and container. `WebContext` reads the request, the routes and the view cache from the container and stores the response to send; `ConsoleContext` reads the Symfony `Application`, the input and the output from the container.
- `AppContext` keeps `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`, and carries the state of a run: `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. `AppContext` also records the app type: its constructor takes a third, required `$type` (`AppType::WEB` or `AppType::CONSOLE`) and `getType()` returns it. `AppFactory` passes it; code that creates an `AppContext` itself must pass it too.
- The new `WebContext` and `ConsoleContext` extend `AppContext` with the same base directory, container and app type. `WebContext` reads the request, the routes and the view cache from the container and stores the response to send; `ConsoleContext` reads the Symfony `Application`, the input and the output from the container.
- The web runtime runs the preflight, route resolution, not found, prepare and dispatch stages, with `SendResponseStage` as its closing stage. Behavior is unchanged.
- The console boot registers the Symfony `Application`, the input and the output in the container (`InitConsoleStage`), loads the helpers, registers the core commands, loads the environment, the config and the error handler, names the application from `app.name` and `app.version` (`UNKNOWN` without them), and registers the app commands last, so their constructors can use `env()` and `config()`. The console runtime runs the command and rejects a missing or unknown one. Registration goes through the new `Quantum\Console\CommandRegistrar`. Removed `ConsoleAppAdapter::createApplication()`: get the application with `Di::get(Application::class)`.
- The console no longer decides by the `core:env` command name. Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`. When the `.env` file is missing or malformed, the new `ResolveEnvironmentStage` stops the boot for a core command that sets it to `false` (`core:env`) and for Symfony's `list` and `help`, so they still run; every other command fails with the exception of the environment file. With a valid `.env`, `core:env` now runs the full boot. A core command constructor must not use the environment or any config, since the core commands are registered before the environment is loaded.
Expand All @@ -47,6 +48,8 @@ The format is based on Keep a Changelog.
- **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533)
- **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533)
- Applied the pending Rector refactors across `src/` (null-coalescing assignment for lazy initialization, explicit `instanceof` checks instead of truthiness checks on nullable objects, native return types on `env()`, `CurlAdapter::getId()` and `CurlAdapter::getResponseCookies()`) and added `composer rector:check` to the CI workflow after PHPStan (#587)
- In debug mode the console logs to the configured adapter, or the one a caller asks for, at every level. Before, debug mode always used the in-memory `message` adapter, so the console's log lines were never written anywhere, and `CronManager`'s `single` logger was replaced too. The web app keeps the `message` adapter for the debug toolbar (#593)
- The error handler chooses the console or the web output by the app type instead of checking `PHP_SAPI`; `php qt`, `php -S` and PHP-FPM behave as before (#593)

### Fixed
- The page not found response is JSON for every client that asks for JSON, not only when the `Accept` header is exactly `application/json`: clients such as axios (`application/json, text/plain, */*`), a `charset` parameter, `+json` types and AJAX requests used to get the HTML page. `page_not_found_response()` decides with `Request::expectsJson()` and takes an optional request (#581)
Expand All @@ -55,6 +58,10 @@ The format is based on Keep a Changelog.
- The debugbar `routes` tab shows the matched route again (methods, pattern, module, controller, action or `Closure`, name, group, prefix, middlewares and params) next to the rendered view; since 3.0.0 it showed only the view path (#539)
- The debugbar `queries` tab lists the queries the request ran; it has been empty since 2.6.0 (#539)
- `SmtpAdapter` records the SMTP exchange in debug mode: it checked `config()->get('debug')`, which is never set, instead of `app.debug`, so the exchange never reached the `mails` tab (#539)
- Idiorm keeps no query log when debug mode is off. It received `app.debug` unconverted, and `DEBUG=false` from `.env` arrives as the text `"false"`, which PHP reads as true, so every query was kept in memory, also in production (#593)
- `mailer.mail_trap` set from `.env` is read as a boolean: `MAIL_TRAP=false` arrives as the text `"false"`, which kept every mail in the trap instead of sending it (#593)
- A "remember me" login works with `REMEMBER_LIFETIME` set in `.env`: the session auth adapter passed the text on to `Cookie::set()`, which takes an `int`, and threw a `TypeError` (#593)
- The Redis and Memcached cache adapters and the Redis rate limiter accept a `port` set from `.env`: `Memcached::addServer()` takes an `int` and threw a `TypeError` for the text (#593)

## [3.0.3] - 2026-07-10

Expand Down
2 changes: 1 addition & 1 deletion src/App/Adapters/Console/ConsoleContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class ConsoleContext extends AppContext
{
public function __construct(AppContext $appContext)
{
parent::__construct($appContext->getBaseDir(), $appContext->getContainer());
parent::__construct($appContext->getBaseDir(), $appContext->getContainer(), $appContext->getType());
}

public function getApplication(): Application
Expand Down
2 changes: 1 addition & 1 deletion src/App/Adapters/Web/WebContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class WebContext extends AppContext

public function __construct(AppContext $appContext)
{
parent::__construct($appContext->getBaseDir(), $appContext->getContainer());
parent::__construct($appContext->getBaseDir(), $appContext->getContainer(), $appContext->getType());
}

public function getRequest(): Request
Expand Down
18 changes: 16 additions & 2 deletions src/App/AppContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
/**
* Class AppContext
*
* Carries the base directory and the container of an application, and the state of the run it is passed through:
* Carries the base directory, the container and the type (web or console) of an application, and the state of the run it is passed through:
* the stopped flag, which ends the pipeline run early, and the exit code.
*
* @package Quantum\App
Expand All @@ -29,14 +29,20 @@ class AppContext

private DiContainer $container;

private string $type;

private bool $stopped = false;

private int $exitCode = ExitCode::SUCCESS;

public function __construct(string $baseDir, DiContainer $container)
/**
* @param string $type AppType::WEB or AppType::CONSOLE
*/
public function __construct(string $baseDir, DiContainer $container, string $type)
{
$this->baseDir = $baseDir;
$this->container = $container;
$this->type = $type;
}

public function getBaseDir(): string
Expand All @@ -49,6 +55,14 @@ public function getContainer(): DiContainer
return $this->container;
}

/**
* Gets the type of the application: AppType::WEB or AppType::CONSOLE
*/
public function getType(): string
{
return $this->type;
}

public function getEnvironment(): Environment
{
return $this->resolveFromContainer(Environment::class);
Expand Down
2 changes: 1 addition & 1 deletion src/App/Factories/AppFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ private static function createInstance(string $type, string $baseDir): App

$container = new DiContainer();

$context = new AppContext($baseDir, $container);
$context = new AppContext($baseDir, $container, $type);
App::setContext($context);

$adapterClass = self::ADAPTERS[$type];
Expand Down
19 changes: 19 additions & 0 deletions src/App/Helpers/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,22 @@
* An open-source software development framework for PHP
* @link https://quantumphp.io
*/

use Quantum\App\Enums\AppType;
use Quantum\App\App;

/**
* Checks whether the running application is the web app
*/
function is_web_app(): bool
{
return App::getContext()->getType() === AppType::WEB;
}

/**
* Checks whether the running application is the console app
*/
function is_console_app(): bool
{
return App::getContext()->getType() === AppType::CONSOLE;
}
2 changes: 1 addition & 1 deletion src/Auth/Adapters/SessionAuthAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ private function setRememberToken(User $user): void
[$this->keyFields[AuthKeys::REMEMBER_TOKEN] => $rememberToken]
);

$rememberLifetime = $this->config['session']['remember_lifetime'] ?? self::DEFAULT_REMEMBER_LIFETIME;
$rememberLifetime = (int) ($this->config['session']['remember_lifetime'] ?? self::DEFAULT_REMEMBER_LIFETIME);

cookie()->set(
$this->keyFields[AuthKeys::REMEMBER_TOKEN],
Expand Down
2 changes: 1 addition & 1 deletion src/Cache/Adapters/MemcachedAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function __construct(array $params)
$this->prefix = $params['prefix'];

$this->memcached = new Memcached();
$this->memcached->addServer($params['host'], $params['port']);
$this->memcached->addServer($params['host'], (int) $params['port']);

if (!$this->memcached->getStats()) {
throw CacheException::cantConnect('Memcached server');
Expand Down
2 changes: 1 addition & 1 deletion src/Cache/Adapters/RedisAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function __construct(array $params)
$this->prefix = $params['prefix'];

$this->redis = new Redis();
$this->redis->connect($params['host'], $params['port']);
$this->redis->connect($params['host'], (int) $params['port']);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/Database/Adapters/Idiorm/IdiormDbal.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
use Quantum\Database\Exceptions\DatabaseException;
use Quantum\Database\Contracts\DbalInterface;
use Quantum\App\Exceptions\BaseException;
use Quantum\Di\Exceptions\DiException;
use InvalidArgumentException;
use ReflectionException;
use ORM;
use PDO;
use Quantum\Di\Exceptions\DiException;
use ReflectionException;

/**
* Class IdiormDbal
Expand Down Expand Up @@ -259,7 +259,7 @@ protected static function getBaseConfig(string $driver, array $config): array
{
return [
'connection_string' => self::buildConnectionString($driver, $config),
'logging' => config()->get('app.debug', false),
'logging' => is_debug_mode(),
'error_mode' => PDO::ERRMODE_EXCEPTION,
];
}
Expand Down
14 changes: 14 additions & 0 deletions src/Logger/Adapters/MessageAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,23 @@ class MessageAdapter implements ReportableInterface
*/
private array $messages = [];

/**
* The number of last messages kept; 0 keeps every message
*/
private int $limit;

public function __construct(int $limit = 1000)
{
$this->limit = $limit;
}

public function report(string $level, string|Stringable $message, ?array $context = []): void
{
$this->messages[] = [$level => $message];

if ($this->limit > 0 && count($this->messages) > $this->limit) {
$this->messages = array_slice($this->messages, -$this->limit);
}
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Logger/Factories/LoggerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function resolve(?string $adapter = null): Logger
throw LoggerException::adapterNotSupported(LoggerType::MESSAGE);
}

$adapter = $isDebug ? LoggerType::MESSAGE : ($adapter ?? config()->get('logging.default'));
$adapter = $isDebug && is_web_app() ? LoggerType::MESSAGE : ($adapter ?? config()->get('logging.default'));

$adapterClass = $this->getAdapterClass($adapter);

Expand All @@ -86,7 +86,7 @@ public function resolve(?string $adapter = null): Logger
private function createInstance(string $adapterClass, string $adapter): Logger
{
if ($adapter === LoggerType::MESSAGE) {
return new Logger(new MessageAdapter());
return new Logger(new MessageAdapter((int) config()->get('logging.message.limit', 1000)));
}

$adapterInstance = new $adapterClass(config()->get('logging.' . $adapter));
Expand Down
11 changes: 9 additions & 2 deletions src/Mailer/Traits/MailerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ trait MailerTrait
protected array $stringAttachments = [];

/**
* Transport log
* Transport log, cut to its last mailer.transport_log_limit lines at the start of each send
* @var array<string>
*/
private array $transportLog = [];
Expand Down Expand Up @@ -222,9 +222,16 @@ public function getBody()
*/
public function send(): bool
{
// Cut here, as every send reaches this point, also after the previous one threw
$limit = (int) config()->get('mailer.transport_log_limit', 1000);

if ($limit > 0 && count($this->transportLog) > $limit) {
$this->transportLog = array_slice($this->transportLog, -$limit);
}

$this->prepare();

$sent = config()->get('mailer.mail_trap') ? $this->saveEmail() : $this->sendEmail();
$sent = filter_var(config()->get('mailer.mail_trap'), FILTER_VALIDATE_BOOLEAN) ? $this->saveEmail() : $this->sendEmail();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

$this->resetFields();

Expand Down
2 changes: 1 addition & 1 deletion src/RateLimit/Adapters/RedisRateLimitAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function __construct(array $params)
$this->prefix = (string) ($params['prefix'] ?? '');

$this->redis = new Redis();
$this->redis->connect($params['host'], $params['port']);
$this->redis->connect($params['host'], (int) $params['port']);
}

public function hit(string $key, int $limit, int $interval): bool
Expand Down
2 changes: 1 addition & 1 deletion src/Tracer/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public function handleError(int $severity, string $message, string $file, int $l
*/
public function handleException(Throwable $throwable): void
{
if (PHP_SAPI === 'cli') {
if (is_console_app()) {
$this->handleCliException($throwable);
} else {
$this->handleWebException($throwable);
Expand Down
Loading
Loading