diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc797c4715..a2f72fdac2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,30 @@
All notable changes to this project will be documented in this file.
-## [2.12.0](https://github.com/tempestphp/tempest-framework/compare/v2.11.0..2.12.0) — 2025-11-28
+## [2.13.0](https://github.com/tempestphp/tempest-framework/compare/v2.12.0..2.13.0) — 2025-12-04
+
+### 🚀 Features
+
+- **auth**: add OAuth installer (#1674) ([9c82b71](https://github.com/tempestphp/tempest-framework/commit/9c82b715b448633a704591e9b78823da28debc98))
+- **cache**: make `assertLocked` ensure that the checked lock has an expiration (#1758) ([1a2e8fb](https://github.com/tempestphp/tempest-framework/commit/1a2e8fbe90259d2bd5a8a0876d4b8fed35c5dcd7))
+- **container**: make all container properties publicly readable (#1785) ([be93ec1](https://github.com/tempestphp/tempest-framework/commit/be93ec1388ec7d253637705d4335d13a78a39f00))
+- **database**: add support for self-referencing relations (#1745) ([df2dcdc](https://github.com/tempestphp/tempest-framework/commit/df2dcdc231384d2dd359f8b621f0ae1f31a3e703))
+- **http**: add support to mark Request properties as #[SensitiveField] (#1746) ([0000c99](https://github.com/tempestphp/tempest-framework/commit/0000c99251b31d0bfa84389fb101be1560d916c3))
+
+### 🐛 Bug fixes
+
+- **auth**: correctly map user in GitHub OAuth provider (#1751) ([ad2182a](https://github.com/tempestphp/tempest-framework/commit/ad2182ac40684b78752e7f7511228688f5093c1a))
+- **auth**: pass scopes/options to auth URL builder (#1750) ([cbe54d7](https://github.com/tempestphp/tempest-framework/commit/cbe54d7f3f7e137fe43e9ad7f8837bd2f7103e9a))
+- **auth**: update outdated authenticatable import (#1752) ([5c68b96](https://github.com/tempestphp/tempest-framework/commit/5c68b968763229dfb5a78c01a80df3b1b134e6c0))
+- **cache**: support enum tags (#1756) ([678b695](https://github.com/tempestphp/tempest-framework/commit/678b69582e526e25ff545c346179bda9636f1415))
+- **cache**: add descriptions to `cache:clear` arguments (#1755) ([e324f6e](https://github.com/tempestphp/tempest-framework/commit/e324f6e767b50acd6e76e8310be12422b85e782b))
+- **command-bus**: extract uuid from pending commands when not provided (#1761) ([b787c16](https://github.com/tempestphp/tempest-framework/commit/b787c16e57f60de3bd7883944561f02fce3a661a))
+- **console**: properly normalize boolean flag names (#1762) ([c6e6867](https://github.com/tempestphp/tempest-framework/commit/c6e6867ede678b9798386bab12e1e2afaef91bc8))
+- **core**: gracefully handle missing seeders when using `db:seed` (#1759) ([450ca75](https://github.com/tempestphp/tempest-framework/commit/450ca7576c6e5a8f4f5719dd27e7d4d4a29954c9))
+- **process**: properly return exit code if missing (#1776) ([9ad1587](https://github.com/tempestphp/tempest-framework/commit/9ad158747a810db490aef43a7a6c1bcfe062d900))
+
+
+## [2.12.0](https://github.com/tempestphp/tempest-framework/compare/v2.11.0..v2.12.0) — 2025-11-28
### 🚀 Features
@@ -798,7 +821,7 @@ All notable changes to this project will be documented in this file.
- rector (#680) ([7fdff1d](https://github.com/tempestphp/tempest-framework/commit/7fdff1d7be48ab91fb35e1a07434ae54ef47781c))
-## [1.0.0-alpha.3](https://github.com/tempestphp/tempest-framework/compare/v1.0.0-alpha.2..v1.0.0-alpha.3) — 2024-10-30
+## [1.0.0-alpha.3](https://github.com/tempestphp/tempest-framework/compare/v1.0.0-alpha.2..v1.0.0-alpha.3) — 2024-10-31
### 🚨 Breaking changes
diff --git a/composer.json b/composer.json
index fef33c3c10..411d6500d0 100644
--- a/composer.json
+++ b/composer.json
@@ -49,6 +49,7 @@
"adam-paterson/oauth2-slack": "^1.1",
"aws/aws-sdk-php": "^3.338.0",
"azure-oss/storage-blob-flysystem": "^1.2",
+ "brianium/paratest": "^7.14",
"carthage-software/mago": "1.0.0-beta.28",
"depotwarehouse/oauth2-twitch": "^1.3",
"guzzlehttp/psr7": "^2.6.1",
@@ -87,8 +88,7 @@
"tempest/blade": "dev-main",
"thenetworg/oauth2-azure": "^2.2",
"twig/twig": "^3.16",
- "wohali/oauth2-discord-new": "^1.2",
- "brianium/paratest": "^7.14"
+ "wohali/oauth2-discord-new": "^1.2"
},
"replace": {
"tempest/auth": "self.version",
diff --git a/docs/2-features/09-logging.md b/docs/2-features/09-logging.md
index 0b98496ca3..26be2f9d6e 100644
--- a/docs/2-features/09-logging.md
+++ b/docs/2-features/09-logging.md
@@ -1,96 +1,196 @@
---
title: Logging
+description: "Learn how to use Tempest's logging features to monitor and debug your application."
---
-Logging is an essential part of any developer's job. Whether it's for debugging or for production monitoring. Tempest has a powerful set of tools to help you access the relevant information you need.
+## Overview
-## Debug log
+Tempest provides a logging implementation built on top of [Monolog](https://github.com/Seldaek/monolog) that follows PSR-3 and the [RFC 5424 specification](https://datatracker.ietf.org/doc/html/rfc5424). This gives you access to eight standard log levels and the ability to send log messages to multiple destinations simultaneously.
-First up are Tempest's debug functions: `ld()` (log, die), `lw()` (log, write), and `ll()` (log, log). These three functions are similar to Symfony's var dumper and Laravel's `dd()`, although there's an important difference.
+The system supports file logging, Slack integration, system logs, and custom channels. You can configure different loggers for different parts of your application using Tempest's [tagged singletons](../1-essentials/05-container.md#tagged-singletons) feature.
-You can think of `ld()` or `lw()` as Laravel's `dd()` and `dump()` variants. In fact, Tempest uses Symfony's var-dumper under the hood, just like Laravel. Furthermore, if you haven't installed Tempest in a project that already includes Laravel, Tempest will also provide `dd()` and `dump()` as aliases to `ld()` and `lw()`.
+## Writing logs
-The main difference is that Tempest's debug functions will **also write to the debug log**, which can be tailed with tempest's built-in `tail` command. This is its default output:
+To start logging messsages, you may inject the {b`Tempest\Log\Logger`} interface in any class. By default, log messages will be written to a daily rotating log file stored in `.tempest/logs`. This may be customized by providing a different [logging configuration](#configuration).
-```console
-./tempest tail
+```php app/Services/UserService.php
+use Tempest\Log\Logger;
-
Project
Listening at /Users/brent/Dev/tempest-docs/log/tempest.log
-Server
No server log configured in LogConfig
-Debug
Listening at /Users/brent/Dev/tempest-docs/log/debug.log
+final readonly class UserService
+{
+ public function __construct(
+ private Logger $logger,
+ ) {}
+}
```
-Wherever you call `ld()` or `lw()` from, the output will also be written to the debug log, and tailed automatically with the `./tempest tail` command. On top of that, `tail` also monitors two other logs:
+Tempest supports all eight levels described in the [RFC 5424](https://tools.ietf.org/html/rfc5424) specification. It is possible to configure channels to only log messages at or above a certain level.
-- The **project log**, which contains everything the default logger writes to
-- The **server log**, which should be manually configured in `LogConfig`:
+```php
+$logger->emergency('System is unusable');
+$logger->alert('Action required immediately');
+$logger->critical('Important, unexpected error');
+$logger->error('Runtime error that should be monitored');
+$logger->warning('Exceptional occurrence that is not an error');
+$logger->notice('Uncommon event');
+$logger->info('Miscellaneous event');
+$logger->debug('Detailed debug information');
+```
+
+### Providing context
+
+All log methods accept an optional context array for additional information. This data is formatted as JSON and included with your log message:
```php
-// app/Config/log.config.php
+$logger->error('Order processing failed', context: [
+ 'user_id' => $order->userId,
+ 'order_id' => $order->id,
+ 'total_amount' => $order->total,
+ 'payment_method' => $order->paymentMethod,
+ 'error_code' => $exception->getCode(),
+ 'error_message' => $exception->getMessage(),
+]);
+```
-use Tempest\Log\LogConfig;
+## Configuration
-return new LogConfig(
- serverLogPath: '/path/to/nginx.log'
+By default, Tempest uses a daily rotating log configuration that creates a new log file each day and retains up to 31 files:
- // …
+```php config/logging.config.php
+use Tempest\Log\Config\DailyLogConfig;
+use Tempest;
+
+return new DailyLogConfig(
+ path: Tempest\internal_storage_path('logs', 'tempest.log'),
+ maxFiles: Tempest\env('LOG_MAX_FILES', default: 31)
);
```
-If you're only interested in tailing one or more specific logs, you can filter the `tail` output like so:
+To configure a different logging channel, you may create a `logging.config.php` file anywhere and return one of the [available configuration classes](#available-configurations-and-channels).
+
+### Specifying a minimum log level
+
+Every configuration class and log channel accept a `minimumLogLevel` property, which defines the lowest severity level that will be logged. Messages below this level will be ignored.
-```console
-./tempest tail --debug
+```php config/logging.config.php
+use Tempest\Log\Config\MultipleChannelsLogConfig;
+use Tempest\Log\Channels\DailyLogChannel;
+use Tempest\Log\Channels\SlackLogChannel;
+use Tempest;
-Debug
Listening at /Users/brent/Dev/tempest-docs/log/debug.log
+return new MultipleChannelsLogConfig(
+ channels: [
+ new DailyLogChannel(
+ path: Tempest\internal_storage_path('logs', 'tempest.log'),
+ maxFiles: Tempest\env('LOG_MAX_FILES', default: 31),
+ minimumLogLevel: LogLevel::DEBUG,
+ ),
+ new SlackLogChannel(
+ webhookUrl: Tempest\env('SLACK_LOGGING_WEBHOOK_URL'),
+ channelId: '#alerts',
+ minimumLogLevel: LogLevel::CRITICAL,
+ ),
+ ],
+);
```
-Finally, the `ll()` function will do exactly the same as `lw()`, but **only write to the debug log, and not output anything in the browser or terminal**.
+### Using multiple loggers
-## Logging channels
+In situations where you would like to log different types of information to different places, you may create multiple tagged configurations to create separate loggers for different purposes.
-On top of debug logging, Tempest includes a monolog implementation which allows you to log to one or more channels. Writing to the logger is as simple as injecting `\Tempest\Log\Logger` wherever you'd like:
+For instance, you could have a logger dedicated to critical alerts, while each of your application's module have its own logger:
-```php
-// app/Rss.php
+```php src/Monitoring/logging.config.php
+use Tempest\Log\Config\DailyLogConfig;
+use Modules\Monitoring\Logging;
+use Tempest;
+
+return new SlackLogConfig(
+ webhookUrl: Tempest\env('SLACK_LOGGING_WEBHOOK_URL'),
+ channelId: '#alerts',
+ minimumLogLevel: LogLevel::CRITICAL,
+ tag: Logging::SLACK,
+);
+```
+
+```php src/Orders/logging.config.php
+use Tempest\Log\Config\DailyLogConfig;
+use Modules\Monitoring\Logging;
+use Tempest;
-use Tempest\Console\Console;
-use Tempest\Console\ConsoleCommand;
+return new DailyLogConfig(
+ path: Tempest\internal_storage_path('logs', 'orders.log'),
+ tag: Logging::ORDERS,
+);
+```
+
+Using this approach, you can inject the appropriate logger using [tagged singletons](../1-essentials/05-container.md#tagged-singletons). This gives you the flexibility to customize logging behavior in different parts of your application.
+
+```php src/Orders/ProcessOrder.php
use Tempest\Log\Logger;
-final readonly class Rss
+final readonly class ProcessOrder
{
public function __construct(
- private Console $console,
+ #[Tag(Logging::ORDERS)]
private Logger $logger,
) {}
- #[ConsoleCommand]
- public function sync()
+ public function __invoke(Order $order): void
{
- $this->logger->info('Starting RSS sync');
-
- // …
+ $this->logger->info('Processing new order', ['order' => $order]);
+
+ // ...
}
}
```
-If you're familiar with [monolog](https://seldaek.github.io/monolog/), you know how it supports multiple handlers to handle a log message. Tempest adds a small layer on top of these handlers called channels, they can be configured within `LogConfig`:
+### Available configurations and channels
-```php
-// app/Config/log.config.php
+Tempest provides a few log channels that correspond to common logging needs:
-use Tempest\Log\LogConfig;
-use Tempest\Log\Channels\AppendLogChannel;
+- {b`Tempest\Log\Channel\AppendLogChannel`} — append all messages to a single file without rotation,
+- {b`Tempest\Log\Channel\DailyLogChannel`} — create a new file each day and remove old files automatically,
+- {b`Tempest\Log\Channel\WeeklyLogChannel`} — create a new file each week and remove old files automatically,
+- {b`Tempest\Log\Channel\SlackLogChannel`} — send messages to a Slack channel via webhook,
+- {b`Tempest\Log\Channel\SysLogChannel`} — write messages to the system log.
-return new LogConfig(
- channels: [
- new AppendLogChannel(path: __DIR__ . '/../log/project.log'),
- ]
-);
-```
+As a convenient abstraction, a configuration class for each channel is provided:
+
+- {b`Tempest\Log\Config\SimpleLogConfig`}
+- {b`Tempest\Log\Config\DailyLogConfig`}
+- {b`Tempest\Log\Config\WeeklyLogConfig`}
+- {b`Tempest\Log\Config\SlackLogConfig`}
+- {b`Tempest\Log\Config\SysLogConfig`}
+
+These configuration classes also accept a `channels` property, which allows for providing multiple channels for a single logger. Alternatively, you may use the {b`Tempest\Log\Config\MultipleChannelsLogConfig`} configuration class to achieve the same result more explicitly.
-**Please note:**
+## Debugging
-- Currently, Tempest only supports the `AppendLogChannel` and `DailyLogChannel`, but we're adding more channels in the future. You can always add your own channels by implementing `\Tempest\Log\LogChannel`.
-- Also, it's currently not possible to configure environment-specific logging channels, this we'll also support in the future. Again, you're free to make your own channels that take the current environment into account.
+Tempest includes several global functions for debugging. Typically, these functions are for quick debugging and should not be committed to production.
+
+- `ll()` — writes values to the debug log without displaying them,
+- `lw()` (also `dump()`) — logs values and displays them,
+- `ld()` (also `dd()`) — logs values, displays them, and stops execution,
+- `le()` — logs values and emits an {b`Tempest\Debug\ItemsDebugged`} event.
+
+### Tailing debug logs
+
+Debug logs are written with console formatting, so they can be tailed with syntax highlighting. You may use `./tempest tail:debug` to monitor the debug log in real time.
+
+:::warning
+By default, debug logs are cleared every time the `tail:debug` command is run. If you want to keep previous log entries, you may pass the `--no-clear` flag.
+:::
+
+### Configuring the debug log
+
+By default, the debug log is written to `.tempest/debug.log`. This is configurable by creating a `debug.config.php` file that returns a {b`Tempest\Debug\DebugConfig`} with a different `path`:
+
+```php config/debug.config.php
+use Tempest\Debug\DebugConfig;
+use Tempest;
+
+return new DebugConfig(
+ logPath: Tempest\internal_storage_path('logs', 'debug.log')
+);
+```
diff --git a/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php b/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php
index dcabe5ac07..fc8387ba95 100644
--- a/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php
+++ b/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php
@@ -3,7 +3,7 @@
namespace Tempest\Auth\Exceptions;
use Exception;
-use Tempest\Auth\Authenticatable;
+use Tempest\Auth\Authentication\Authenticatable;
final class AuthenticatableModelWasInvalid extends Exception implements AuthenticationException
{
diff --git a/packages/auth/src/OAuth/GenericOAuthClient.php b/packages/auth/src/OAuth/GenericOAuthClient.php
index e3eba0b2ee..9759711227 100644
--- a/packages/auth/src/OAuth/GenericOAuthClient.php
+++ b/packages/auth/src/OAuth/GenericOAuthClient.php
@@ -62,7 +62,7 @@ public function buildAuthorizationUrl(array $scopes = [], array $options = []):
public function createRedirect(array $scopes = [], array $options = []): Redirect
{
- $to = $this->buildAuthorizationUrl();
+ $to = $this->buildAuthorizationUrl($scopes, $options);
$this->session->set($this->sessionKey, $this->provider->getState());
@@ -100,7 +100,12 @@ public function fetchUser(AccessToken $token): OAuthUser
public function authenticate(Request $request, Closure $map): Authenticatable
{
- if ($this->session->get($this->sessionKey) !== $request->get('state')) {
+ $expectedState = $this->session->get($this->sessionKey);
+ $actualState = $request->get('state');
+
+ $this->session->remove($this->sessionKey);
+
+ if ($expectedState !== $actualState) {
throw new OAuthStateWasInvalid();
}
diff --git a/packages/auth/src/OAuth/Testing/TestingOAuthClient.php b/packages/auth/src/OAuth/Testing/TestingOAuthClient.php
index 5e00ec4b4c..470cf0ccd7 100644
--- a/packages/auth/src/OAuth/Testing/TestingOAuthClient.php
+++ b/packages/auth/src/OAuth/Testing/TestingOAuthClient.php
@@ -9,6 +9,7 @@
use PHPUnit\Framework\Assert;
use Tempest\Auth\Authentication\Authenticatable;
use Tempest\Auth\Authentication\Authenticator;
+use Tempest\Auth\Exceptions\OAuthStateWasInvalid;
use Tempest\Auth\OAuth\OAuthClient;
use Tempest\Auth\OAuth\OAuthConfig;
use Tempest\Auth\OAuth\OAuthUser;
@@ -64,11 +65,11 @@ public function buildAuthorizationUrl(array $scopes = [], array $options = []):
$this->state = Random\secure_string(16);
$provider = $this->config->createProvider();
- $provider->getBaseAuthorizationUrl();
+ $baseAuthorizationUrl = $provider->getBaseAuthorizationUrl();
$url = sprintf(
'%s/oauth/authorize?redirect_uri=%s&client_id=%s&state=%s',
- $this->baseUrl ?? $provider->getBaseAuthorizationUrl(),
+ $this->baseUrl ?? $baseAuthorizationUrl,
$this->redirectUri,
$this->clientId,
$this->state,
@@ -124,6 +125,15 @@ public function createRedirect(array $scopes = [], array $options = []): Redirec
public function authenticate(Request $request, Closure $map): Authenticatable
{
+ $expectedState = $this->state;
+ $actualState = $request->get('state');
+
+ $this->state = null;
+
+ if ($expectedState !== $actualState) {
+ throw new OAuthStateWasInvalid();
+ }
+
$user = $this->fetchUser($this->requestAccessToken($request->get('code')));
$authenticatable = $map($user);
diff --git a/packages/cache/src/Commands/CacheClearCommand.php b/packages/cache/src/Commands/CacheClearCommand.php
index bcf2434a57..e4022e28ab 100644
--- a/packages/cache/src/Commands/CacheClearCommand.php
+++ b/packages/cache/src/Commands/CacheClearCommand.php
@@ -38,9 +38,9 @@ public function __construct(
public function __invoke(
#[ConsoleArgument(description: 'Name of the tagged cache to clear')]
?string $tag = null,
- #[ConsoleCommand(description: 'Whether to clear all caches')]
+ #[ConsoleArgument(description: 'Whether to clear all caches')]
bool $all = false,
- #[ConsoleCommand(description: 'Whether to clear internal caches')]
+ #[ConsoleArgument(description: 'Whether to clear internal caches')]
bool $internal = false,
): void {
if (! $this->container instanceof GenericContainer) {
diff --git a/packages/cache/src/Commands/CacheStatusCommand.php b/packages/cache/src/Commands/CacheStatusCommand.php
index c7b93c0aca..61e45da515 100644
--- a/packages/cache/src/Commands/CacheStatusCommand.php
+++ b/packages/cache/src/Commands/CacheStatusCommand.php
@@ -38,7 +38,7 @@ public function __construct(
public function __invoke(bool $internal = true): void
{
if (! $this->container instanceof GenericContainer) {
- $this->console->error('Clearing caches is only available when using the default container.');
+ $this->console->error('Checking cache status is only available when using the default container.');
return;
}
diff --git a/packages/cache/src/GenericCache.php b/packages/cache/src/GenericCache.php
index df378a6e6e..1d59a34f00 100644
--- a/packages/cache/src/GenericCache.php
+++ b/packages/cache/src/GenericCache.php
@@ -130,6 +130,13 @@ public function get(Stringable|string $key): mixed
public function getMany(iterable $key): array
{
+ if (! $this->enabled) {
+ return Arr\map_with_keys(
+ array: $key,
+ map: fn (string|Stringable $key) => yield (string) $key => null,
+ );
+ }
+
return Arr\map_with_keys(
array: $key,
map: fn (string|Stringable $key) => yield (string) $key => $this->adapter->getItem((string) $key)->get(),
diff --git a/packages/cache/src/InternalCacheInsightsProvider.php b/packages/cache/src/InternalCacheInsightsProvider.php
index f6a3fe812f..5e8cd9b788 100644
--- a/packages/cache/src/InternalCacheInsightsProvider.php
+++ b/packages/cache/src/InternalCacheInsightsProvider.php
@@ -4,6 +4,7 @@
use Tempest\Core\ConfigCache;
use Tempest\Core\DiscoveryCache;
+use Tempest\Core\DiscoveryCacheStrategy;
use Tempest\Core\Insight;
use Tempest\Core\InsightsProvider;
use Tempest\Icon\IconCache;
@@ -26,7 +27,11 @@ public function getInsights(): array
'Discovery' => match ($this->discoveryCache->valid) {
false => new Insight('Invalid', Insight::ERROR),
true => match ($this->discoveryCache->enabled) {
- true => new Insight('Enabled', Insight::ERROR),
+ true => match ($this->discoveryCache->strategy) {
+ DiscoveryCacheStrategy::FULL => new Insight('Enabled', Insight::SUCCESS),
+ DiscoveryCacheStrategy::PARTIAL => new Insight('Enabled (partial)', Insight::SUCCESS),
+ default => null, // INVALID and NONE are handled
+ },
false => new Insight('Disabled', Insight::WARNING),
},
},
diff --git a/packages/cache/src/Testing/RestrictedCache.php b/packages/cache/src/Testing/RestrictedCache.php
index 71ca12fc5f..eb7e5d4655 100644
--- a/packages/cache/src/Testing/RestrictedCache.php
+++ b/packages/cache/src/Testing/RestrictedCache.php
@@ -10,67 +10,73 @@
use Tempest\Cache\Lock;
use Tempest\DateTime\DateTimeInterface;
use Tempest\DateTime\Duration;
+use UnitEnum;
final class RestrictedCache implements Cache
{
public bool $enabled;
public function __construct(
- private ?string $tag = null,
+ private null|string|UnitEnum $tag = null,
) {}
+ private function resolveTag(): ?string
+ {
+ return $this->tag instanceof UnitEnum ? $this->tag->name : $this->tag;
+ }
+
public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function has(Stringable|string $key): bool
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function putMany(iterable $values, null|Duration|DateTimeInterface $expiration = null): array
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function increment(Stringable|string $key, int $by = 1): int
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function decrement(Stringable|string $key, int $by = 1): int
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function get(Stringable|string $key): mixed
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function getMany(iterable $key): array
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function remove(Stringable|string $key): void
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
public function clear(): void
{
- throw new CacheUsageWasForbidden($this->tag);
+ throw new CacheUsageWasForbidden($this->resolveTag());
}
}
diff --git a/packages/cache/src/Testing/TestingLock.php b/packages/cache/src/Testing/TestingLock.php
index 9dcaf26cee..e1c0a873e1 100644
--- a/packages/cache/src/Testing/TestingLock.php
+++ b/packages/cache/src/Testing/TestingLock.php
@@ -66,6 +66,11 @@ public function assertLocked(null|Stringable|string $by = null, null|DateTimeInt
$until = DateTime::now()->plus($until);
}
+ Assert::assertNotNull(
+ actual: $this->expiration,
+ message: "Expected lock `{$this->key}` to have an expiration, but it has none.",
+ );
+
Assert::assertTrue(
condition: $this->expiration->afterOrAtTheSameTime($until),
message: "Expected lock `{$this->key}` to expire at or after `{$until}`, but it expires at `{$this->expiration}`.",
diff --git a/packages/console/src/Commands/TailCommand.php b/packages/console/src/Commands/TailCommand.php
deleted file mode 100644
index be8edd600c..0000000000
--- a/packages/console/src/Commands/TailCommand.php
+++ /dev/null
@@ -1,61 +0,0 @@
- $loggers */
- $loggers = array_filter([
- $shouldFilter === false || $project ? $this->tailProjectLogCommand : null,
- $shouldFilter === false || $server ? $this->tailServerLogCommand : null,
- $shouldFilter === false || $debug ? $this->tailDebugLogCommand : null,
- ]);
-
- /** @var Fiber[] $fibers */
- $fibers = [];
-
- foreach ($loggers as $key => $logger) {
- $fiber = new Fiber(fn () => $logger());
- $fibers[$key] = $fiber;
- $fiber->start();
- }
-
- while ($fibers !== []) {
- foreach ($fibers as $key => $fiber) {
- if ($fiber->isSuspended()) {
- $fiber->resume();
- }
-
- if ($fiber->isTerminated()) {
- unset($fibers[$key]);
- }
- }
- }
- }
-}
diff --git a/packages/console/src/Commands/TailDebugLogCommand.php b/packages/console/src/Commands/TailDebugLogCommand.php
deleted file mode 100644
index 4dd60e9e50..0000000000
--- a/packages/console/src/Commands/TailDebugLogCommand.php
+++ /dev/null
@@ -1,55 +0,0 @@
-logConfig->debugLogPath;
-
- if (! $debugLogPath) {
- $this->console->error('No debug log configured in LogConfig.');
-
- return;
- }
-
- $dir = pathinfo($debugLogPath, PATHINFO_DIRNAME);
-
- if (! is_dir($dir)) {
- mkdir($dir);
- }
-
- if (! file_exists($debugLogPath)) {
- touch($debugLogPath);
- }
-
- $this->console->header('Tailing debug logs', "Reading …");
-
- new TailReader()->tail(
- path: $debugLogPath,
- format: fn (string $text) => $this->highlighter->parse(
- $text,
- new VarExportLanguage(),
- ),
- );
- }
-}
diff --git a/packages/console/src/Commands/TailServerLogCommand.php b/packages/console/src/Commands/TailServerLogCommand.php
deleted file mode 100644
index 42d5cebe0c..0000000000
--- a/packages/console/src/Commands/TailServerLogCommand.php
+++ /dev/null
@@ -1,51 +0,0 @@
-logConfig->serverLogPath;
-
- if (! $serverLogPath) {
- $this->console->error('No server log configured in LogConfig.');
-
- return;
- }
-
- if (! file_exists($serverLogPath)) {
- $this->console->error("No valid server log at ");
-
- return;
- }
-
- $this->console->header('Tailing server logs', "Reading …");
-
- new TailReader()->tail(
- path: $serverLogPath,
- format: fn (string $text) => $this->highlighter->parse(
- $text,
- new LogLanguage(),
- ),
- );
- }
-}
diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php
index 5be785208b..bc1342c741 100644
--- a/packages/container/src/GenericContainer.php
+++ b/packages/container/src/GenericContainer.php
@@ -26,20 +26,20 @@ final class GenericContainer implements Container
public function __construct(
/** @var ArrayIterator $definitions */
- private ArrayIterator $definitions = new ArrayIterator(),
+ private(set) ArrayIterator $definitions = new ArrayIterator(),
/** @var ArrayIterator $singletons */
- private ArrayIterator $singletons = new ArrayIterator(),
+ private(set) ArrayIterator $singletons = new ArrayIterator(),
/** @var ArrayIterator $initializers */
- public ArrayIterator $initializers = new ArrayIterator(),
+ private(set) ArrayIterator $initializers = new ArrayIterator(),
/** @var ArrayIterator $dynamicInitializers */
- private ArrayIterator $dynamicInitializers = new ArrayIterator(),
+ private(set) ArrayIterator $dynamicInitializers = new ArrayIterator(),
/** @var ArrayIterator $decorators */
- private ArrayIterator $decorators = new ArrayIterator(),
- private ?DependencyChain $chain = null,
+ private(set) ArrayIterator $decorators = new ArrayIterator(),
+ private(set) ?DependencyChain $chain = null,
) {}
public function setDefinitions(array $definitions): self
diff --git a/packages/core/src/Kernel.php b/packages/core/src/Kernel.php
index 878dc281ce..1ce183e2f5 100644
--- a/packages/core/src/Kernel.php
+++ b/packages/core/src/Kernel.php
@@ -8,7 +8,7 @@
interface Kernel
{
- public const string VERSION = '2.12.0';
+ public const string VERSION = '2.13.0';
public string $root {
get;
diff --git a/packages/core/src/LogExceptionProcessor.php b/packages/core/src/LogExceptionProcessor.php
index f74569f675..f851866de3 100644
--- a/packages/core/src/LogExceptionProcessor.php
+++ b/packages/core/src/LogExceptionProcessor.php
@@ -3,6 +3,7 @@
namespace Tempest\Core;
use Tempest\Debug\Debug;
+use Tempest\Log\Logger;
use Throwable;
/**
@@ -10,6 +11,10 @@
*/
final class LogExceptionProcessor implements ExceptionProcessor
{
+ public function __construct(
+ private readonly Logger $logger,
+ ) {}
+
public function process(Throwable $throwable): void
{
$items = [
@@ -21,6 +26,8 @@ public function process(Throwable $throwable): void
: [],
];
+ $this->logger->error($throwable->getMessage(), $items);
+
Debug::resolve()->log($items, writeToOut: false);
}
}
diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php
index bbb0917a1e..0cf62c3fbf 100644
--- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php
+++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php
@@ -145,7 +145,7 @@ private function convertObjectToArray(object $object, array $excludeProperties =
$data = [];
foreach ($reflection->getPublicProperties() as $property) {
- if (! $property->isInitialized($object)) {
+ if ($property->isUninitialized($object)) {
continue;
}
@@ -153,10 +153,6 @@ private function convertObjectToArray(object $object, array $excludeProperties =
continue;
}
- if ($property->isUninitialized($object)) {
- continue;
- }
-
$propertyName = $property->getName();
if (! in_array($propertyName, $excludeProperties, strict: true)) {
diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php
index fed474be4c..1e1b43b626 100644
--- a/packages/database/src/Migrations/MigrationManager.php
+++ b/packages/database/src/Migrations/MigrationManager.php
@@ -247,10 +247,6 @@ public function executeDown(MigratesDown $migration): void
$statement = $migration->down();
- if ($statement === null) {
- return;
- }
-
$query = new Query($statement->compile($this->dialect));
try {
@@ -261,10 +257,10 @@ public function executeDown(MigratesDown $migration): void
$this->database->execute($query);
- // Disable foreign key checks
+ // Enable foreign key checks
new SetForeignKeyChecksStatement(enable: true)->execute($this->dialect, $this->onDatabase);
} catch (QueryWasInvalid $queryWasInvalid) {
- // Disable foreign key checks
+ // Enable foreign key checks
new SetForeignKeyChecksStatement(enable: true)->execute($this->dialect, $this->onDatabase);
event(new MigrationFailed($migration->name, $queryWasInvalid));
diff --git a/packages/database/src/Transactions/GenericTransactionManager.php b/packages/database/src/Transactions/GenericTransactionManager.php
index c43ab8daa5..d9e73d2c60 100644
--- a/packages/database/src/Transactions/GenericTransactionManager.php
+++ b/packages/database/src/Transactions/GenericTransactionManager.php
@@ -35,7 +35,7 @@ public function commit(): void
public function rollback(): void
{
- $transactionRolledBack = $this->connection->rollBack();
+ $transactionRolledBack = $this->connection->rollback();
if (! $transactionRolledBack) {
throw new CouldNotRollbackTransaction();
diff --git a/packages/datetime/src/DateTimeInterface.php b/packages/datetime/src/DateTimeInterface.php
index bf010a1131..069b9edce3 100644
--- a/packages/datetime/src/DateTimeInterface.php
+++ b/packages/datetime/src/DateTimeInterface.php
@@ -228,7 +228,7 @@ public function getMonthEnum(): Month;
/**
* Returns the day.
*
- * @return int<0, 31>
+ * @return int<1, 31>
*/
public function getDay(): int;
diff --git a/packages/datetime/src/Exception/UnexpectedValueException.php b/packages/datetime/src/Exception/UnexpectedValueException.php
index 4f4f7ab01d..69ebcda060 100644
--- a/packages/datetime/src/Exception/UnexpectedValueException.php
+++ b/packages/datetime/src/Exception/UnexpectedValueException.php
@@ -117,8 +117,8 @@ public static function forSeconds(int $provided_seconds, int $calendar_seconds):
{
return new self(sprintf(
'Unexpected seconds value encountered. Provided "%d", but the calendar expects "%d". Ensure the seconds are correct and within the 0-59 range.',
- $calendar_seconds,
$provided_seconds,
+ $calendar_seconds,
));
}
}
diff --git a/packages/datetime/tests/DateTimeTest.php b/packages/datetime/tests/DateTimeTest.php
index b6e5912df7..056b6f18bc 100644
--- a/packages/datetime/tests/DateTimeTest.php
+++ b/packages/datetime/tests/DateTimeTest.php
@@ -164,7 +164,7 @@ public static function provide_invalid_component_parts(): array
0,
],
[
- 'Unexpected seconds value encountered. Provided "59", but the calendar expects "-1". Ensure the seconds are correct and within the 0-59 range.',
+ 'Unexpected seconds value encountered. Provided "-1", but the calendar expects "59". Ensure the seconds are correct and within the 0-59 range.',
2024,
1,
1,
diff --git a/packages/debug/composer.json b/packages/debug/composer.json
index d5eae92891..bdf25a63d0 100644
--- a/packages/debug/composer.json
+++ b/packages/debug/composer.json
@@ -5,6 +5,7 @@
"minimum-stability": "dev",
"require": {
"php": "^8.4",
+ "tempest/console": "dev-main",
"tempest/highlight": "^2.11.4",
"symfony/var-dumper": "^7.1"
},
diff --git a/packages/debug/src/Debug.php b/packages/debug/src/Debug.php
index 3022dabf80..5a6f957c07 100644
--- a/packages/debug/src/Debug.php
+++ b/packages/debug/src/Debug.php
@@ -11,29 +11,33 @@
use Tempest\Container\GenericContainer;
use Tempest\EventBus\EventBus;
use Tempest\Highlight\Themes\TerminalStyle;
-use Tempest\Log\LogConfig;
+use Tempest\Support\Filesystem;
final readonly class Debug
{
private function __construct(
- private ?LogConfig $logConfig = null,
+ private ?DebugConfig $config = null,
private ?EventBus $eventBus = null,
) {}
public static function resolve(): self
{
try {
- $container = GenericContainer::instance();
-
return new self(
- logConfig: $container?->get(LogConfig::class),
- eventBus: $container?->get(EventBus::class),
+ config: GenericContainer::instance()->get(DebugConfig::class),
+ eventBus: GenericContainer::instance()->get(EventBus::class),
);
} catch (Exception) {
return new self();
}
}
+ /**
+ * Logs and/or dumps the given items.
+ *
+ * @param bool $writeToLog Whether to write the items to the log file.
+ * @param bool $writeToOut Whether to dump the items to the standard output.
+ */
public function log(array $items, bool $writeToLog = true, bool $writeToOut = true): void
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
@@ -52,30 +56,19 @@ public function log(array $items, bool $writeToLog = true, bool $writeToOut = tr
private function writeToLog(array $items, string $callPath): void
{
- if ($this->logConfig === null) {
- return;
- }
-
- if (! $this->logConfig->debugLogPath) {
+ if (! $this->config?->logPath) {
return;
}
- $directory = dirname($this->logConfig->debugLogPath);
-
- if (! is_dir($directory)) {
- mkdir(directory: $directory, recursive: true);
- }
-
- $handle = @fopen($this->logConfig->debugLogPath, 'a');
+ Filesystem\create_directory_for_file($this->config->logPath);
- if (! $handle) {
+ if (! ($handle = @fopen($this->config->logPath, 'a'))) {
return;
}
foreach ($items as $key => $item) {
- $output = $this->createDump($item) . $callPath;
-
- fwrite($handle, "{$key} " . $output . PHP_EOL);
+ fwrite($handle, TerminalStyle::BG_BLUE(" {$key} ") . TerminalStyle::FG_GRAY(' → ' . TerminalStyle::ITALIC($callPath)));
+ fwrite($handle, $this->createCliDump($item) . PHP_EOL);
}
fclose($handle);
@@ -86,27 +79,27 @@ private function writeToOut(array $items, string $callPath): void
foreach ($items as $key => $item) {
if (defined('STDOUT')) {
fwrite(STDOUT, TerminalStyle::BG_BLUE(" {$key} ") . ' ');
-
- $output = $this->createDump($item);
-
- fwrite(STDOUT, $output);
-
- fwrite(STDOUT, $callPath . PHP_EOL);
+ fwrite(STDOUT, $this->createCliDump($item));
+ fwrite(STDOUT, TerminalStyle::DIM('→ ' . TerminalStyle::ITALIC($callPath)) . PHP_EOL . PHP_EOL);
} else {
echo
- sprintf(
- '%s (%s)',
- 'Source Code Pro, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',
- $key,
- $callPath,
+ vsprintf(
+ <<%s (%s)
+ HTML,
+ [
+ 'Source Code Pro, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',
+ $key,
+ $callPath,
+ ],
)
;
@@ -115,10 +108,9 @@ private function writeToOut(array $items, string $callPath): void
}
}
- private function createDump(mixed $input): string
+ private function createCliDump(mixed $input): string
{
$cloner = new VarCloner();
-
$output = '';
$dumper = new CliDumper(function ($line, $depth) use (&$output): void {
@@ -130,7 +122,6 @@ private function createDump(mixed $input): string
});
$dumper->setColors(true);
-
$dumper->dump($cloner->cloneVar($input));
return preg_replace(
diff --git a/packages/debug/src/DebugConfig.php b/packages/debug/src/DebugConfig.php
new file mode 100644
index 0000000000..11cc9fad50
--- /dev/null
+++ b/packages/debug/src/DebugConfig.php
@@ -0,0 +1,13 @@
+debugConfig->logPath;
+
+ if (! $debugLogPath) {
+ $this->console->error('No debug log configured in DebugConfig.');
+
+ return;
+ }
+
+ if ($clear && Filesystem\is_file($debugLogPath)) {
+ Filesystem\delete_file($debugLogPath);
+ }
+
+ Filesystem\create_file($debugLogPath);
+
+ $this->console->header('Tailing debug logs', "Reading …");
+
+ new TailReader()->tail($debugLogPath);
+ }
+}
diff --git a/packages/debug/src/debug.config.php b/packages/debug/src/debug.config.php
new file mode 100644
index 0000000000..30e24ba3f1
--- /dev/null
+++ b/packages/debug/src/debug.config.php
@@ -0,0 +1,7 @@
+parseIconIdentifier($icon);
+ [$collection, $iconName] = $this->parseIconIdentifier($icon) ?? [null, null];
if ($this->iconCache->get("icon-failure-{$collection}-{$iconName}")) {
return null;
diff --git a/packages/icon/src/IconCache.php b/packages/icon/src/IconCache.php
index 565ad5e802..d630707998 100644
--- a/packages/icon/src/IconCache.php
+++ b/packages/icon/src/IconCache.php
@@ -43,6 +43,10 @@ public function get(string $key): mixed
public function clear(): void
{
+ if (! $this->enabled) {
+ return;
+ }
+
$this->pool->clear();
}
diff --git a/packages/icon/src/IconInitializer.php b/packages/icon/src/IconInitializer.php
index 2bcf2d9a74..629d41cc7c 100644
--- a/packages/icon/src/IconInitializer.php
+++ b/packages/icon/src/IconInitializer.php
@@ -7,7 +7,6 @@
use Tempest\Container\Singleton;
use Tempest\EventBus\EventBus;
use Tempest\HttpClient\HttpClient;
-use Tempest\Icon\IconCache;
final class IconInitializer implements Initializer
{
diff --git a/packages/kv-store/src/Redis/Config/RedisConfig.php b/packages/kv-store/src/Redis/Config/RedisConfig.php
index 3b84127777..ec63e7759c 100644
--- a/packages/kv-store/src/Redis/Config/RedisConfig.php
+++ b/packages/kv-store/src/Redis/Config/RedisConfig.php
@@ -38,7 +38,7 @@ public function __construct(
/**
* Path of the UNIX domain socket file used when connecting to Redis using UNIX domain sockets.
*/
- public ?int $unixSocketPath = null,
+ public ?string $unixSocketPath = null,
/**
* Specifies the protocol used to communicate with the Redis instance. This is specific to predis.
diff --git a/packages/kv-store/src/Redis/PhpRedisClient.php b/packages/kv-store/src/Redis/PhpRedisClient.php
index 89f5882298..8002e1486b 100644
--- a/packages/kv-store/src/Redis/PhpRedisClient.php
+++ b/packages/kv-store/src/Redis/PhpRedisClient.php
@@ -99,7 +99,7 @@ public function command(Stringable|string $command, Stringable|string ...$argume
public function set(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): void
{
if ($expiration instanceof DateTimeInterface) {
- $expiration = DateTime::now()->since($expiration);
+ $expiration = $expiration->since(DateTime::now());
}
if ($expiration?->isNegative()) {
diff --git a/packages/kv-store/src/Redis/PredisClient.php b/packages/kv-store/src/Redis/PredisClient.php
index 5bc5068157..002a8bd3a4 100644
--- a/packages/kv-store/src/Redis/PredisClient.php
+++ b/packages/kv-store/src/Redis/PredisClient.php
@@ -56,7 +56,7 @@ public function command(Stringable|string $command, Stringable|string ...$argume
public function set(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): void
{
if ($expiration instanceof DateTimeInterface) {
- $expiration = DateTime::now()->since($expiration);
+ $expiration = $expiration->since(DateTime::now());
}
if ($expiration?->isNegative()) {
diff --git a/packages/kv-store/src/Redis/ReddisExtensionWasMissing.php b/packages/kv-store/src/Redis/RedisExtensionWasMissing.php
similarity index 83%
rename from packages/kv-store/src/Redis/ReddisExtensionWasMissing.php
rename to packages/kv-store/src/Redis/RedisExtensionWasMissing.php
index 8d331a2dd3..0e595127bb 100644
--- a/packages/kv-store/src/Redis/ReddisExtensionWasMissing.php
+++ b/packages/kv-store/src/Redis/RedisExtensionWasMissing.php
@@ -5,7 +5,7 @@
use Exception;
use Predis;
-final class ReddisExtensionWasMissing extends Exception implements RedisException
+final class RedisExtensionWasMissing extends Exception implements RedisException
{
public function __construct(string $fqcn)
{
diff --git a/packages/kv-store/src/Redis/RedisInitializer.php b/packages/kv-store/src/Redis/RedisInitializer.php
index c291ec7432..9eeccfe570 100644
--- a/packages/kv-store/src/Redis/RedisInitializer.php
+++ b/packages/kv-store/src/Redis/RedisInitializer.php
@@ -19,7 +19,7 @@ public function initialize(Container $container): Redis
try {
return new PhpRedisClient($this->buildPhpRedisClient(), $config, $bus);
- } catch (ReddisExtensionWasMissing) {
+ } catch (RedisExtensionWasMissing) {
return new PredisClient($this->buildPredisClient($config), $bus);
}
}
@@ -27,7 +27,7 @@ public function initialize(Container $container): Redis
private function buildPhpRedisClient(): \Redis
{
if (! extension_loaded('redis') || ! class_exists(\Redis::class)) {
- throw new ReddisExtensionWasMissing(\Redis::class);
+ throw new RedisExtensionWasMissing(\Redis::class);
}
return new \Redis();
@@ -36,7 +36,7 @@ private function buildPhpRedisClient(): \Redis
private function buildPredisClient(RedisConfig $config): Predis\Client
{
if (! class_exists(Predis\Client::class)) {
- throw new ReddisExtensionWasMissing(Predis\Client::class);
+ throw new RedisExtensionWasMissing(Predis\Client::class);
}
return new Predis\Client(
diff --git a/packages/log/src/Channels/AppendLogChannel.php b/packages/log/src/Channels/AppendLogChannel.php
index 0fc52f6a84..35de7b4e37 100644
--- a/packages/log/src/Channels/AppendLogChannel.php
+++ b/packages/log/src/Channels/AppendLogChannel.php
@@ -8,18 +8,31 @@
use Monolog\Level;
use Monolog\Processor\PsrLogMessageProcessor;
use Tempest\Log\LogChannel;
+use Tempest\Log\LogLevel;
final readonly class AppendLogChannel implements LogChannel
{
+ /**
+ * @param string $path The log file path.
+ * @param bool $useLocking Whether to try to lock log file before doing any writes.
+ * @param LogLevel $minimumLogLevel The minimum log level to record.
+ * @param bool $bubble Whether the messages that are handled can bubble up the stack or not
+ * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write).
+ */
public function __construct(
- private string $path,
- private bool $bubble = true,
- private ?int $filePermission = null,
- private bool $useLocking = false,
+ private(set) string $path,
+ private(set) bool $useLocking = false,
+ private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG,
+ private(set) bool $bubble = true,
+ private(set) ?int $filePermission = null,
) {}
public function getHandlers(Level $level): array
{
+ if (! $this->minimumLogLevel->includes(LogLevel::fromMonolog($level))) {
+ return [];
+ }
+
return [
new StreamHandler(
stream: $this->path,
@@ -37,9 +50,4 @@ public function getProcessors(): array
new PsrLogMessageProcessor(),
];
}
-
- public function getPath(): string
- {
- return $this->path;
- }
}
diff --git a/packages/log/src/Channels/DailyLogChannel.php b/packages/log/src/Channels/DailyLogChannel.php
index 18534524d0..938c165760 100644
--- a/packages/log/src/Channels/DailyLogChannel.php
+++ b/packages/log/src/Channels/DailyLogChannel.php
@@ -8,27 +8,42 @@
use Monolog\Processor\PsrLogMessageProcessor;
use Tempest\Log\FileHandlers\RotatingFileHandler;
use Tempest\Log\LogChannel;
+use Tempest\Log\LogLevel;
final readonly class DailyLogChannel implements LogChannel
{
+ /**
+ * This channel writes logs to a file that is rotated daily.
+ *
+ * @param string $path The base log file name.
+ * @param int $maxFiles The maximal amount of files to keep (0 means unlimited)
+ * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes.
+ * @param bool $bubble Whether the messages that are handled can bubble up the stack or not
+ * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write)
+ */
public function __construct(
private string $path,
private int $maxFiles = 31,
+ private LogLevel $minimumLogLevel = LogLevel::DEBUG,
+ private bool $lockFilesDuringWrites = false,
private bool $bubble = true,
private ?int $filePermission = null,
- private bool $useLocking = false,
) {}
public function getHandlers(Level $level): array
{
+ if (! $this->minimumLogLevel->includes(LogLevel::fromMonolog($level))) {
+ return [];
+ }
+
return [
new RotatingFileHandler(
filename: $this->path,
- maxFiles: $this->maxFiles,
+ maxFiles: $this->maxFiles ?? 0,
level: $level,
bubble: $this->bubble,
filePermission: $this->filePermission,
- useLocking: $this->useLocking,
+ useLocking: $this->lockFilesDuringWrites,
dateFormat: RotatingFileHandler::FILE_PER_DAY,
),
];
diff --git a/packages/log/src/Channels/Slack/PresentationMode.php b/packages/log/src/Channels/Slack/PresentationMode.php
new file mode 100644
index 0000000000..76b25ce962
--- /dev/null
+++ b/packages/log/src/Channels/Slack/PresentationMode.php
@@ -0,0 +1,21 @@
+minimumLogLevel->includes(LogLevel::fromMonolog($level))) {
+ return [];
+ }
+
+ return [
+ new SlackWebhookHandler(
+ webhookUrl: $this->webhookUrl,
+ channel: $this->channelId,
+ username: $this->username,
+ level: $level,
+ useAttachment: $this->mode === PresentationMode::BLOCKS || $this->mode === PresentationMode::BLOCKS_WITH_CONTEXT,
+ includeContextAndExtra: $this->mode === PresentationMode::BLOCKS_WITH_CONTEXT,
+ ),
+ ];
+ }
+
+ public function getProcessors(): array
+ {
+ return [
+ new PsrLogMessageProcessor(),
+ ];
+ }
+}
diff --git a/packages/log/src/Channels/SysLogChannel.php b/packages/log/src/Channels/SysLogChannel.php
new file mode 100644
index 0000000000..5e5caa78da
--- /dev/null
+++ b/packages/log/src/Channels/SysLogChannel.php
@@ -0,0 +1,53 @@
+minimumLogLevel->includes(LogLevel::fromMonolog($level))) {
+ return [];
+ }
+
+ return [
+ new SyslogHandler(
+ ident: $this->identity,
+ facility: $this->facility,
+ level: $level,
+ bubble: $this->bubble,
+ logopts: $this->flags,
+ ),
+ ];
+ }
+
+ public function getProcessors(): array
+ {
+ return [
+ new PsrLogMessageProcessor(),
+ ];
+ }
+}
diff --git a/packages/log/src/Channels/WeeklyLogChannel.php b/packages/log/src/Channels/WeeklyLogChannel.php
index af52ab6898..007d582ffe 100644
--- a/packages/log/src/Channels/WeeklyLogChannel.php
+++ b/packages/log/src/Channels/WeeklyLogChannel.php
@@ -8,19 +8,33 @@
use Monolog\Processor\PsrLogMessageProcessor;
use Tempest\Log\FileHandlers\RotatingFileHandler;
use Tempest\Log\LogChannel;
+use Tempest\Log\LogLevel;
final readonly class WeeklyLogChannel implements LogChannel
{
+ /**
+ * @param string $path The base log file name.
+ * @param int $maxFiles The maximal amount of files to keep.
+ * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes.
+ * @param LogLevel $minimumLogLevel The minimum log level to record.
+ * @param bool $bubble Whether the messages that are handled can bubble up the stack or not.
+ * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write).
+ */
public function __construct(
private string $path,
private int $maxFiles = 5,
+ private bool $lockFilesDuringWrites = false,
+ private LogLevel $minimumLogLevel = LogLevel::DEBUG,
private bool $bubble = true,
private ?int $filePermission = null,
- private bool $useLocking = false,
) {}
public function getHandlers(Level $level): array
{
+ if (! $this->minimumLogLevel->includes(LogLevel::fromMonolog($level))) {
+ return [];
+ }
+
return [
new RotatingFileHandler(
filename: $this->path,
@@ -28,7 +42,7 @@ public function getHandlers(Level $level): array
level: $level,
bubble: $this->bubble,
filePermission: $this->filePermission,
- useLocking: $this->useLocking,
+ useLocking: $this->lockFilesDuringWrites,
dateFormat: RotatingFileHandler::FILE_PER_WEEK,
),
];
diff --git a/packages/log/src/Config/DailyLogConfig.php b/packages/log/src/Config/DailyLogConfig.php
new file mode 100644
index 0000000000..281c7ef383
--- /dev/null
+++ b/packages/log/src/Config/DailyLogConfig.php
@@ -0,0 +1,47 @@
+ [
+ new DailyLogChannel(
+ path: $this->path,
+ maxFiles: $this->maxFiles,
+ minimumLogLevel: $this->minimumLogLevel,
+ lockFilesDuringWrites: $this->lockFilesDuringWrites,
+ filePermission: $this->filePermission,
+ ),
+ ...$this->channels,
+ ];
+ }
+
+ /**
+ * A logging configuration that creates a new log file each day and retains a maximum number of files.
+ *
+ * @param string $path The base log file name.
+ * @param int $maxFiles The maximal amount of files to keep (0 means unlimited)
+ * @param LogLevel $minimumLogLevel The minimum log level to record.
+ * @param array $channels Additional channels to include in the configuration.
+ * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes.
+ * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write)
+ * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used.
+ * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration.
+ */
+ public function __construct(
+ private(set) string $path,
+ private(set) int $maxFiles = 31,
+ private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG,
+ private(set) array $channels = [],
+ private(set) bool $lockFilesDuringWrites = false,
+ private(set) ?int $filePermission = null,
+ private(set) ?string $prefix = null,
+ private(set) null|UnitEnum|string $tag = null,
+ ) {}
+}
diff --git a/packages/log/src/Config/MultipleChannelsLogConfig.php b/packages/log/src/Config/MultipleChannelsLogConfig.php
new file mode 100644
index 0000000000..7ffef9125a
--- /dev/null
+++ b/packages/log/src/Config/MultipleChannelsLogConfig.php
@@ -0,0 +1,27 @@
+ $this->channels;
+ }
+
+ /**
+ * A logging configuration that uses multiple log channels.
+ *
+ * @param LogChannel[] $channels The log channels to which log messages will be sent.
+ * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used.
+ * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration.
+ */
+ public function __construct(
+ private(set) array $channels,
+ private(set) ?string $prefix,
+ private(set) null|UnitEnum|string $tag = null,
+ ) {}
+}
diff --git a/packages/log/src/Config/NullLogConfig.php b/packages/log/src/Config/NullLogConfig.php
new file mode 100644
index 0000000000..d72a4b711e
--- /dev/null
+++ b/packages/log/src/Config/NullLogConfig.php
@@ -0,0 +1,21 @@
+ [];
+ }
+
+ /**
+ * A logging configuration that does not log anything.
+ */
+ public function __construct(
+ private(set) ?string $prefix = null,
+ private(set) null|UnitEnum|string $tag = null,
+ ) {}
+}
diff --git a/packages/log/src/Config/SimpleLogConfig.php b/packages/log/src/Config/SimpleLogConfig.php
new file mode 100644
index 0000000000..3d12dfcbad
--- /dev/null
+++ b/packages/log/src/Config/SimpleLogConfig.php
@@ -0,0 +1,44 @@
+ [
+ new AppendLogChannel(
+ path: $this->path,
+ useLocking: $this->useLocking,
+ minimumLogLevel: $this->minimumLogLevel,
+ filePermission: $this->filePermission,
+ ),
+ ...$this->channels,
+ ];
+ }
+
+ /**
+ * A basic logging configuration that appends all logs to a single file.
+ *
+ * @param string $path The log file path.
+ * @param LogLevel $minimumLogLevel The minimum log level to record.
+ * @param array $channels Additional channels to include in the configuration.
+ * @param bool $useLocking Whether to try to lock log file before doing any writes.
+ * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write).
+ * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used.
+ * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration.
+ */
+ public function __construct(
+ private(set) string $path,
+ private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG,
+ private(set) array $channels = [],
+ private(set) bool $useLocking = false,
+ private(set) ?int $filePermission = null,
+ private(set) ?string $prefix = null,
+ private(set) null|UnitEnum|string $tag = null,
+ ) {}
+}
diff --git a/packages/log/src/Config/SlackLogConfig.php b/packages/log/src/Config/SlackLogConfig.php
new file mode 100644
index 0000000000..b4f471d6fc
--- /dev/null
+++ b/packages/log/src/Config/SlackLogConfig.php
@@ -0,0 +1,46 @@
+ [
+ new SlackLogChannel(
+ webhookUrl: $this->webhookUrl,
+ channelId: $this->channelId,
+ username: $this->username,
+ mode: $this->mode,
+ minimumLogLevel: $this->minimumLogLevel,
+ ),
+ ...$this->channels,
+ ];
+ }
+
+ /**
+ * A logging configuration for sending log messages to a Slack channel using an Incoming Webhook.
+ *
+ * @param string $webhookUrl The Slack Incoming Webhook URL.
+ * @param string|null $channelId The Slack channel ID to send messages to. If null, the default channel configured in the webhook will be used.
+ * @param string|null $username The username to display as the sender of the message.
+ * @param PresentationMode $mode The display mode for the Slack messages.
+ * @param LogLevel $minimumLogLevel The minimum log level to record.
+ * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used.
+ * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration.
+ */
+ public function __construct(
+ private(set) string $webhookUrl,
+ private(set) ?string $channelId = null,
+ private(set) ?string $username = null,
+ private(set) PresentationMode $mode = PresentationMode::INLINE,
+ private LogLevel $minimumLogLevel = LogLevel::DEBUG,
+ private(set) ?string $prefix = null,
+ private(set) null|UnitEnum|string $tag = null,
+ ) {}
+}
diff --git a/packages/log/src/Config/WeeklyLogConfig.php b/packages/log/src/Config/WeeklyLogConfig.php
new file mode 100644
index 0000000000..3b57e3e077
--- /dev/null
+++ b/packages/log/src/Config/WeeklyLogConfig.php
@@ -0,0 +1,47 @@
+ [
+ new WeeklyLogChannel(
+ path: $this->path,
+ maxFiles: $this->maxFiles,
+ lockFilesDuringWrites: $this->lockFilesDuringWrites,
+ minimumLogLevel: $this->minimumLogLevel,
+ filePermission: $this->filePermission,
+ ),
+ ...$this->channels,
+ ];
+ }
+
+ /**
+ * A logging configuration that creates a new log file each week and retains a maximum number of files.
+ *
+ * @param string $path The base log file name.
+ * @param int $maxFiles The maximal amount of files to keep.
+ * @param LogLevel $minimumLogLevel The minimum log level to record.
+ * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used.
+ * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes.
+ * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write).
+ * @param array $channels Additional channels to include in the configuration.
+ * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration.
+ */
+ public function __construct(
+ private(set) string $path,
+ private(set) int $maxFiles = 5,
+ private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG,
+ private(set) array $channels = [],
+ private(set) ?string $prefix = null,
+ private(set) bool $lockFilesDuringWrites = false,
+ private(set) ?int $filePermission = null,
+ private(set) null|UnitEnum|string $tag = null,
+ ) {}
+}
diff --git a/packages/log/src/Config/logs.config.php b/packages/log/src/Config/logs.config.php
deleted file mode 100644
index 1b16a3c766..0000000000
--- a/packages/log/src/Config/logs.config.php
+++ /dev/null
@@ -1,14 +0,0 @@
- */
+ /** @var array */
private array $drivers = [];
public function __construct(
private readonly LogConfig $logConfig,
+ private readonly AppConfig $appConfig,
private readonly EventBus $eventBus,
) {}
@@ -82,9 +84,9 @@ public function log(mixed $level, Stringable|string $message, array $context = [
$this->eventBus->dispatch(new MessageLogged(LogLevel::fromMonolog($level), $message, $context));
}
- private function writeLog(MonologLogLevel $level, string $message, array $context): void
+ private function writeLog(MonologLogLevel $level, Stringable|string $message, array $context): void
{
- foreach ($this->logConfig->channels as $channel) {
+ foreach ($this->logConfig->logChannels as $channel) {
$this->resolveDriver($channel, $level)->log($level, $message, $context);
}
}
@@ -95,7 +97,7 @@ private function resolveDriver(LogChannel $channel, MonologLogLevel $level): Mon
if (! isset($this->drivers[$key])) {
$this->drivers[$key] = new Monolog(
- name: $this->logConfig->prefix,
+ name: $this->logConfig->prefix ?? $this->appConfig->environment->value,
handlers: $channel->getHandlers($level),
processors: $channel->getProcessors(),
);
diff --git a/packages/log/src/LogConfig.php b/packages/log/src/LogConfig.php
index e9293d98e8..4e3502073f 100644
--- a/packages/log/src/LogConfig.php
+++ b/packages/log/src/LogConfig.php
@@ -4,23 +4,23 @@
namespace Tempest\Log;
-use Tempest\Log\Channels\AppendLogChannel;
+use Tempest\Container\HasTag;
-use function Tempest\root_path;
-
-final class LogConfig
+interface LogConfig extends HasTag
{
- public function __construct(
- /** @var LogChannel[] */
- public array $channels = [],
- public string $prefix = 'tempest',
- public ?string $debugLogPath = null,
- public ?string $serverLogPath = null,
- ) {
- $this->debugLogPath ??= root_path('/log/debug.log');
+ /**
+ * An optional prefix displayed in all log messages. By default, the current environment is used.
+ */
+ public ?string $prefix {
+ get;
+ }
- if ($this->channels === []) {
- $this->channels[] = new AppendLogChannel(root_path('/log/tempest.log'));
- }
+ /**
+ * The log channels to which log messages will be sent.
+ *
+ * @var LogChannel[]
+ */
+ public array $logChannels {
+ get;
}
}
diff --git a/packages/log/src/LogLevel.php b/packages/log/src/LogLevel.php
index 002ccb8a37..f969504646 100644
--- a/packages/log/src/LogLevel.php
+++ b/packages/log/src/LogLevel.php
@@ -61,4 +61,26 @@ public static function fromMonolog(Level $level): self
Level::Debug => self::DEBUG,
};
}
+
+ public function toMonolog(): Level
+ {
+ return match ($this) {
+ self::EMERGENCY => Level::Emergency,
+ self::ALERT => Level::Alert,
+ self::CRITICAL => Level::Critical,
+ self::ERROR => Level::Error,
+ self::WARNING => Level::Warning,
+ self::NOTICE => Level::Notice,
+ self::INFO => Level::Info,
+ self::DEBUG => Level::Debug,
+ };
+ }
+
+ /**
+ * Determines if this log level is higher than or equal to the given level.
+ */
+ public function includes(self $level): bool
+ {
+ return $this->toMonolog()->includes($level->toMonolog());
+ }
}
diff --git a/packages/log/src/LoggerInitializer.php b/packages/log/src/LoggerInitializer.php
index b2bd6d19f7..b3fc0bd5b1 100644
--- a/packages/log/src/LoggerInitializer.php
+++ b/packages/log/src/LoggerInitializer.php
@@ -6,18 +6,27 @@
use Psr\Log\LoggerInterface;
use Tempest\Container\Container;
-use Tempest\Container\Initializer;
+use Tempest\Container\DynamicInitializer;
use Tempest\Container\Singleton;
+use Tempest\Core\AppConfig;
use Tempest\EventBus\EventBus;
+use Tempest\Reflection\ClassReflector;
+use UnitEnum;
-final readonly class LoggerInitializer implements Initializer
+final readonly class LoggerInitializer implements DynamicInitializer
{
+ public function canInitialize(ClassReflector $class, null|string|UnitEnum $tag): bool
+ {
+ return $class->getType()->matches(Logger::class) || $class->getType()->matches(LoggerInterface::class);
+ }
+
#[Singleton]
- public function initialize(Container $container): LoggerInterface|Logger
+ public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Container $container): LoggerInterface|Logger
{
return new GenericLogger(
- $container->get(LogConfig::class),
- $container->get(EventBus::class),
+ logConfig: $container->get(LogConfig::class, $tag),
+ appConfig: $container->get(AppConfig::class),
+ eventBus: $container->get(EventBus::class),
);
}
}
diff --git a/packages/console/src/Commands/TailProjectLogCommand.php b/packages/log/src/TailLogsCommand.php
similarity index 55%
rename from packages/console/src/Commands/TailProjectLogCommand.php
rename to packages/log/src/TailLogsCommand.php
index 0777335f7a..b4cd35543c 100644
--- a/packages/console/src/Commands/TailProjectLogCommand.php
+++ b/packages/log/src/TailLogsCommand.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Tempest\Console\Commands;
+namespace Tempest\Log;
use Tempest\Console\Console;
use Tempest\Console\ConsoleCommand;
@@ -12,53 +12,41 @@
use Tempest\Highlight\Highlighter;
use Tempest\Log\Channels\AppendLogChannel;
use Tempest\Log\LogConfig;
+use Tempest\Support\Filesystem;
-final readonly class TailProjectLogCommand
+final readonly class TailLogsCommand
{
public function __construct(
private Console $console,
- private LogConfig $logConfig,
+ private LogConfig $config,
#[Tag('console')]
private Highlighter $highlighter,
) {}
- #[ConsoleCommand('tail:project', description: 'Tails the project log')]
+ #[ConsoleCommand('tail:logs', description: 'Tails the project logs', aliases: ['log:tail', 'logs:tail'])]
public function __invoke(): void
{
$appendLogChannel = null;
- foreach ($this->logConfig->channels as $channel) {
+ foreach ($this->config->logChannels as $channel) {
if ($channel instanceof AppendLogChannel) {
$appendLogChannel = $channel;
-
break;
}
}
if ($appendLogChannel === null) {
- $this->console->error('No AppendLogChannel registered');
-
+ $this->console->error('Tailing logs is only supported when a AppendLogChannel is configured.');
return;
}
- $dir = pathinfo($appendLogChannel->getPath(), PATHINFO_DIRNAME);
-
- if (! is_dir($dir)) {
- mkdir($dir);
- }
-
- if (! file_exists($appendLogChannel->getPath())) {
- touch($appendLogChannel->getPath());
- }
+ Filesystem\create_file($appendLogChannel->path);
- $this->console->header('Tailing project logs', "Reading getPath()}'/>…");
+ $this->console->header('Tailing project logs', "Reading path}'/>…");
new TailReader()->tail(
- path: $appendLogChannel->getPath(),
- format: fn (string $text) => $this->highlighter->parse(
- $text,
- new LogLanguage(),
- ),
+ path: $appendLogChannel->path,
+ format: fn (string $text) => $this->highlighter->parse($text, new LogLanguage()),
);
}
}
diff --git a/packages/log/src/logging.config.php b/packages/log/src/logging.config.php
new file mode 100644
index 0000000000..227806df96
--- /dev/null
+++ b/packages/log/src/logging.config.php
@@ -0,0 +1,14 @@
+assertSame($expected, LogLevel::fromMonolog($level));
}
diff --git a/packages/mail/src/Attachment.php b/packages/mail/src/Attachment.php
index 7ad4b61b8a..b0a88f9805 100644
--- a/packages/mail/src/Attachment.php
+++ b/packages/mail/src/Attachment.php
@@ -61,7 +61,7 @@ public static function fromFilesystem(string $path, ?string $name = null, ?strin
$path = Path\normalize($path);
if (! Filesystem\is_file($path)) {
- throw new FileAttachmentWasNotFound($path);
+ throw FileAttachmentWasNotFound::forFilesystemFile($path);
}
return new self(
diff --git a/packages/mail/src/Transports/Postmark/PostmarkConfig.php b/packages/mail/src/Transports/Postmark/PostmarkConfig.php
index f04cd863b7..2e8a7741cc 100644
--- a/packages/mail/src/Transports/Postmark/PostmarkConfig.php
+++ b/packages/mail/src/Transports/Postmark/PostmarkConfig.php
@@ -53,7 +53,7 @@ public function __construct(
public function createTransport(): TransportInterface
{
return new PostmarkTransportFactory()->create(new Dsn(
- scheme: PostmarkConnectionScheme::API->value,
+ scheme: $this->scheme->value,
host: $this->host ?? 'default',
user: $this->key,
port: $this->port,
diff --git a/packages/mail/src/mail.config.php b/packages/mail/src/mail.config.php
index b91c2affb5..2865a4b73a 100644
--- a/packages/mail/src/mail.config.php
+++ b/packages/mail/src/mail.config.php
@@ -20,7 +20,7 @@
'smtps' => SmtpScheme::SMTPS,
'smtp' => SmtpScheme::SMTP,
},
- host: env('MAIL_SMTP_HOST', default: '127.0.0.0'),
+ host: env('MAIL_SMTP_HOST', default: '127.0.0.1'),
port: env('MAIL_SMTP_PORT', default: 2525),
username: env('MAIL_SMTP_USERNAME', default: ''),
password: env('MAIL_SMTP_PASSWORD', default: ''),
diff --git a/packages/mapper/src/Casters/EnumCaster.php b/packages/mapper/src/Casters/EnumCaster.php
index c4e6489e3d..3520662d88 100644
--- a/packages/mapper/src/Casters/EnumCaster.php
+++ b/packages/mapper/src/Casters/EnumCaster.php
@@ -30,6 +30,10 @@ public function cast(mixed $input): ?object
return constant("{$this->enum}::{$input}");
}
+ if (! is_a($this->enum, \BackedEnum::class, allow_string: true)) {
+ return null;
+ }
+
return forward_static_call("{$this->enum}::from", $input);
}
}
diff --git a/packages/mapper/src/Mappers/ObjectToArrayMapper.php b/packages/mapper/src/Mappers/ObjectToArrayMapper.php
index beaa946807..2012b2e99b 100644
--- a/packages/mapper/src/Mappers/ObjectToArrayMapper.php
+++ b/packages/mapper/src/Mappers/ObjectToArrayMapper.php
@@ -49,6 +49,10 @@ public function map(mixed $from, mixed $to): mixed
private function resolvePropertyValue(PropertyReflector $property, object $object): mixed
{
+ if (! $property->isInitialized($object)) {
+ return null;
+ }
+
$propertyValue = $property->getValue($object);
if ($property->getIterableType()?->isClass()) {
diff --git a/packages/process/src/InvokedProcessPool.php b/packages/process/src/InvokedProcessPool.php
index 1fa2b51867..1e8598bb12 100644
--- a/packages/process/src/InvokedProcessPool.php
+++ b/packages/process/src/InvokedProcessPool.php
@@ -29,7 +29,7 @@ final class InvokedProcessPool implements Countable
}
public function __construct(
- /** @var MutableArray */
+ /** @var MutableArray */
private MutableArray $processes,
) {}
diff --git a/packages/process/src/ProcessResult.php b/packages/process/src/ProcessResult.php
index e602e2c3fe..ce1cc47504 100644
--- a/packages/process/src/ProcessResult.php
+++ b/packages/process/src/ProcessResult.php
@@ -34,7 +34,7 @@ public function failed(): bool
public static function fromSymfonyProcess(SymfonyProcess $process): self
{
return new self(
- exitCode: $process->getExitCode(),
+ exitCode: $process->getExitCode() ?? -1,
output: $process->getOutput(),
errorOutput: $process->getErrorOutput(),
);
diff --git a/packages/process/src/Testing/InvokedTestingProcess.php b/packages/process/src/Testing/InvokedTestingProcess.php
index d9d5271257..723015b692 100644
--- a/packages/process/src/Testing/InvokedTestingProcess.php
+++ b/packages/process/src/Testing/InvokedTestingProcess.php
@@ -115,11 +115,7 @@ public function signal(int $signal): self
public function stop(float|int|Duration $timeout = 10, ?int $signal = null): self
{
- if ($timeout instanceof Duration) {
- $timeout = $timeout->getTotalSeconds();
- }
-
- $this->process->stop((float) $timeout, $signal);
+ $this->remainingRunIterations = 0;
return $this;
}
diff --git a/packages/process/src/Testing/TestingProcessExecutor.php b/packages/process/src/Testing/TestingProcessExecutor.php
index f67de4192b..9f0308d958 100644
--- a/packages/process/src/Testing/TestingProcessExecutor.php
+++ b/packages/process/src/Testing/TestingProcessExecutor.php
@@ -2,7 +2,6 @@
namespace Tempest\Process\Testing;
-use RuntimeException;
use Tempest\Process\GenericProcessExecutor;
use Tempest\Process\InvokedProcess;
use Tempest\Process\InvokedSystemProcess;
diff --git a/packages/router/src/HttpApplication.php b/packages/router/src/HttpApplication.php
index 78b6e6961d..faee9bc69a 100644
--- a/packages/router/src/HttpApplication.php
+++ b/packages/router/src/HttpApplication.php
@@ -11,11 +11,6 @@
use Tempest\Core\Tempest;
use Tempest\Http\RequestFactory;
use Tempest\Http\Session\SessionManager;
-use Tempest\Log\Channels\AppendLogChannel;
-use Tempest\Log\LogConfig;
-
-use function Tempest\env;
-use function Tempest\Support\path;
#[Singleton]
final readonly class HttpApplication implements Application
@@ -25,24 +20,9 @@ public function __construct(
) {}
/** @param \Tempest\Discovery\DiscoveryLocation[] $discoveryLocations */
- public static function boot(
- string $root,
- array $discoveryLocations = [],
- ): self {
- $container = Tempest::boot($root, $discoveryLocations);
-
- $application = $container->get(HttpApplication::class);
-
- // Application-specific setup
- $logConfig = $container->get(LogConfig::class);
-
- if ($logConfig->debugLogPath === null && $logConfig->serverLogPath === null && $logConfig->channels === []) {
- $logConfig->debugLogPath = path($container->get(Kernel::class)->root, '/log/debug.log')->toString();
- $logConfig->serverLogPath = env('SERVER_LOG');
- $logConfig->channels[] = new AppendLogChannel(path($root, '/log/tempest.log')->toString());
- }
-
- return $application;
+ public static function boot(string $root, array $discoveryLocations = []): self
+ {
+ return Tempest::boot($root, $discoveryLocations)->get(HttpApplication::class);
}
public function run(): void
diff --git a/packages/support/src/Arr/IsIterable.php b/packages/support/src/Arr/IsIterable.php
index ca97e14f75..e293451e42 100644
--- a/packages/support/src/Arr/IsIterable.php
+++ b/packages/support/src/Arr/IsIterable.php
@@ -24,7 +24,7 @@ public function key(): string|int|null
public function valid(): bool
{
- return $this->current() !== false;
+ return $this->key() !== null;
}
public function rewind(): void
diff --git a/packages/support/src/Arr/functions.php b/packages/support/src/Arr/functions.php
index c618e40257..b1a5695d0f 100644
--- a/packages/support/src/Arr/functions.php
+++ b/packages/support/src/Arr/functions.php
@@ -516,6 +516,12 @@ function combine(iterable $array, iterable $values): array
$array = to_array($array);
$values = to_array($values);
+ if (count($array) !== count($values)) {
+ throw new InvalidArgumentException(
+ sprintf('Cannot combine arrays of different lengths (%d keys vs %d values)', count($array), count($values)),
+ );
+ }
+
return array_combine($array, $values);
}
@@ -792,6 +798,10 @@ function get_by_key(iterable $array, int|string $key, mixed $default = null): mi
: explode('.', $key);
foreach ($keys as $key) {
+ if (! is_array($value) && ! $value instanceof \ArrayAccess) {
+ return $default;
+ }
+
if (! isset($value[$key])) {
return $default;
}
@@ -843,11 +853,12 @@ function has_key(iterable $array, int|string $key): bool
*/
function contains(iterable $array, mixed $search): bool
{
+ $array = to_array($array);
$search = $search instanceof Closure
? $search
: static fn (mixed $value) => $value === $search;
- return namespace\first(to_array($array), $search) !== null;
+ return array_any($array, static fn ($value, $key) => $search($value, $key));
}
/**
diff --git a/packages/validation/src/Rules/DoesNotEndWith.php b/packages/validation/src/Rules/DoesNotEndWith.php
index c57623d99b..532f8829a7 100644
--- a/packages/validation/src/Rules/DoesNotEndWith.php
+++ b/packages/validation/src/Rules/DoesNotEndWith.php
@@ -20,6 +20,10 @@ public function __construct(
public function isValid(mixed $value): bool
{
+ if (! is_string($value)) {
+ return false;
+ }
+
return ! str_ends_with($value, $this->needle);
}
diff --git a/packages/validation/src/Rules/DoesNotStartWith.php b/packages/validation/src/Rules/DoesNotStartWith.php
index 8128592019..0e17fae120 100644
--- a/packages/validation/src/Rules/DoesNotStartWith.php
+++ b/packages/validation/src/Rules/DoesNotStartWith.php
@@ -20,6 +20,10 @@ public function __construct(
public function isValid(mixed $value): bool
{
+ if (! is_string($value)) {
+ return false;
+ }
+
return ! str_starts_with($value, $this->needle);
}
diff --git a/packages/validation/src/Rules/EndsWith.php b/packages/validation/src/Rules/EndsWith.php
index 43f639eaec..5e42c038fd 100644
--- a/packages/validation/src/Rules/EndsWith.php
+++ b/packages/validation/src/Rules/EndsWith.php
@@ -20,6 +20,10 @@ public function __construct(
public function isValid(mixed $value): bool
{
+ if (! is_string($value)) {
+ return false;
+ }
+
return str_ends_with($value, $this->needle);
}
diff --git a/packages/validation/src/Rules/HasCount.php b/packages/validation/src/Rules/HasCount.php
index 7d2b91df35..7f8d62bf0b 100644
--- a/packages/validation/src/Rules/HasCount.php
+++ b/packages/validation/src/Rules/HasCount.php
@@ -26,6 +26,10 @@ public function __construct(
public function isValid(mixed $value): bool
{
+ if (! is_array($value) && ! $value instanceof \Countable) {
+ return false;
+ }
+
$length = count($value);
$min = $this->min ?? $length;
diff --git a/packages/validation/src/Rules/HasLength.php b/packages/validation/src/Rules/HasLength.php
index 78641575f0..1e1756f1ea 100644
--- a/packages/validation/src/Rules/HasLength.php
+++ b/packages/validation/src/Rules/HasLength.php
@@ -30,7 +30,7 @@ public function isValid(mixed $value): bool
return false;
}
- $length = strlen($value);
+ $length = mb_strlen($value);
$min = $this->min ?? $length;
$max = $this->max ?? $length;
diff --git a/packages/validation/src/Rules/IsBetween.php b/packages/validation/src/Rules/IsBetween.php
index 0c514c3362..c5e5dfec14 100644
--- a/packages/validation/src/Rules/IsBetween.php
+++ b/packages/validation/src/Rules/IsBetween.php
@@ -21,6 +21,10 @@ public function __construct(
public function isValid(mixed $value): bool
{
+ if (! is_numeric($value)) {
+ return false;
+ }
+
return $value >= $this->min && $value <= $this->max;
}
diff --git a/packages/validation/src/Rules/IsDivisibleBy.php b/packages/validation/src/Rules/IsDivisibleBy.php
index 6ae654dee4..eaa7311d32 100644
--- a/packages/validation/src/Rules/IsDivisibleBy.php
+++ b/packages/validation/src/Rules/IsDivisibleBy.php
@@ -20,11 +20,13 @@ public function __construct(
public function isValid(mixed $value): bool
{
- if (! is_numeric($value) || $value === 0) {
+ if (! is_numeric($value)) {
return false;
}
- return new IsMultipleOf($this->divisor)->isValid($value);
+ $intValue = (int) $value;
+
+ return ($intValue % $this->divisor) === 0;
}
public function getTranslationVariables(): array
diff --git a/packages/validation/src/Rules/IsEnum.php b/packages/validation/src/Rules/IsEnum.php
index 4a53164dcc..ff598942cd 100644
--- a/packages/validation/src/Rules/IsEnum.php
+++ b/packages/validation/src/Rules/IsEnum.php
@@ -62,6 +62,8 @@ enum: $this->enum,
...$this->only,
...(is_array($values) ? $values : func_get_args()),
],
+ except: $this->except,
+ orNull: $this->orNull,
);
}
diff --git a/packages/validation/src/Rules/IsJsonString.php b/packages/validation/src/Rules/IsJsonString.php
index 20ce516ccd..246460543e 100644
--- a/packages/validation/src/Rules/IsJsonString.php
+++ b/packages/validation/src/Rules/IsJsonString.php
@@ -20,6 +20,10 @@ public function __construct(
public function isValid(mixed $value): bool
{
+ if (! is_string($value)) {
+ return false;
+ }
+
$arguments = ['json' => $value];
if ($this->depth !== null) {
diff --git a/packages/validation/src/Rules/IsLowercase.php b/packages/validation/src/Rules/IsLowercase.php
index dc539597d7..e6e3f3d67d 100644
--- a/packages/validation/src/Rules/IsLowercase.php
+++ b/packages/validation/src/Rules/IsLowercase.php
@@ -15,6 +15,10 @@
{
public function isValid(mixed $value): bool
{
+ if (! is_string($value)) {
+ return false;
+ }
+
return $value === mb_strtolower($value);
}
}
diff --git a/packages/validation/src/Rules/IsPassword.php b/packages/validation/src/Rules/IsPassword.php
index 1b723db05f..04447f0815 100644
--- a/packages/validation/src/Rules/IsPassword.php
+++ b/packages/validation/src/Rules/IsPassword.php
@@ -32,7 +32,7 @@ public function isValid(mixed $value): bool
return false;
}
- if (strlen($value) < $this->min) {
+ if (mb_strlen($value) < $this->min) {
return false;
}
diff --git a/packages/validation/src/Rules/IsUnixTimestamp.php b/packages/validation/src/Rules/IsUnixTimestamp.php
index 1bbfd5acb9..b4bb55517c 100644
--- a/packages/validation/src/Rules/IsUnixTimestamp.php
+++ b/packages/validation/src/Rules/IsUnixTimestamp.php
@@ -15,7 +15,7 @@
{
public function isValid(mixed $value): bool
{
- if (! filter_var($value, FILTER_VALIDATE_INT)) {
+ if (filter_var($value, FILTER_VALIDATE_INT) === false) {
return false;
}
diff --git a/packages/validation/src/Rules/IsUppercase.php b/packages/validation/src/Rules/IsUppercase.php
index 3bc8883d91..f59af2508c 100644
--- a/packages/validation/src/Rules/IsUppercase.php
+++ b/packages/validation/src/Rules/IsUppercase.php
@@ -15,6 +15,10 @@
{
public function isValid(mixed $value): bool
{
+ if (! is_string($value)) {
+ return false;
+ }
+
return $value === mb_strtoupper($value);
}
}
diff --git a/packages/validation/src/Rules/StartsWith.php b/packages/validation/src/Rules/StartsWith.php
index 88621a7986..f97028f088 100644
--- a/packages/validation/src/Rules/StartsWith.php
+++ b/packages/validation/src/Rules/StartsWith.php
@@ -20,6 +20,10 @@ public function __construct(
public function isValid(mixed $value): bool
{
+ if (! is_string($value)) {
+ return false;
+ }
+
return str_starts_with($value, $this->needle);
}
diff --git a/packages/validation/tests/Rules/DivisibleByTest.php b/packages/validation/tests/Rules/DivisibleByTest.php
index b57abe09d9..ba9665d92b 100644
--- a/packages/validation/tests/Rules/DivisibleByTest.php
+++ b/packages/validation/tests/Rules/DivisibleByTest.php
@@ -18,7 +18,7 @@ public function test_it_works(): void
$this->assertTrue($rule->isValid(10));
$this->assertTrue($rule->isValid(5));
- $this->assertFalse($rule->isValid(0));
+ $this->assertTrue($rule->isValid(0));
$this->assertFalse($rule->isValid(3));
$this->assertFalse($rule->isValid(4));
diff --git a/packages/vite-plugin-tempest/package.json b/packages/vite-plugin-tempest/package.json
index c79dc63c2c..025f0f8a7c 100644
--- a/packages/vite-plugin-tempest/package.json
+++ b/packages/vite-plugin-tempest/package.json
@@ -1,7 +1,7 @@
{
"name": "vite-plugin-tempest",
"type": "module",
- "version": "2.12.0",
+ "version": "2.13.0",
"author": "Enzo Innocenzi",
"license": "MIT",
"sideEffects": false,
diff --git a/packages/vite-plugin-tempest/src/config.ts b/packages/vite-plugin-tempest/src/config.ts
index ea16f2339d..0a2e1b41e8 100644
--- a/packages/vite-plugin-tempest/src/config.ts
+++ b/packages/vite-plugin-tempest/src/config.ts
@@ -52,7 +52,11 @@ async function loadConfigurationFromTempestConsole(): Promise): {
* Resolve the host name from the environment.
*/
function resolveHostFromEnv(env: Record): string | undefined {
- if (env.VITE_DEV_SERVER_KEY) {
- return env.VITE_DEV_SERVER_KEY
- }
-
try {
return new URL(env.BASE_URI).host
} catch {
diff --git a/packages/vite/src/PrefetchConfig.php b/packages/vite/src/PrefetchConfig.php
index a156bae303..ef3ffbd4ab 100644
--- a/packages/vite/src/PrefetchConfig.php
+++ b/packages/vite/src/PrefetchConfig.php
@@ -13,7 +13,7 @@ public function __construct(
public PrefetchStrategy $strategy = PrefetchStrategy::NONE,
/**
- * Number of assets to prefech concurrently when using the waterfall strategy.
+ * Number of assets to prefetch concurrently when using the waterfall strategy.
*/
public int $concurrent = 3,
diff --git a/src/Tempest/Framework/Commands/DatabaseSeedCommand.php b/src/Tempest/Framework/Commands/DatabaseSeedCommand.php
index 033a834929..1c79e48f3b 100644
--- a/src/Tempest/Framework/Commands/DatabaseSeedCommand.php
+++ b/src/Tempest/Framework/Commands/DatabaseSeedCommand.php
@@ -36,6 +36,12 @@ public function __invoke(
return;
}
+ if ($this->seederConfig->seeders === []) {
+ $this->console->info('No seeders are configured.');
+
+ return;
+ }
+
if (count($this->seederConfig->seeders) === 1) {
$this->runSeeder($this->seederConfig->seeders[0], $database);
return;
diff --git a/src/Tempest/Framework/Commands/MetaViewComponentCommand.php b/src/Tempest/Framework/Commands/MetaViewComponentCommand.php
index 7a877500f7..1ce98f187c 100644
--- a/src/Tempest/Framework/Commands/MetaViewComponentCommand.php
+++ b/src/Tempest/Framework/Commands/MetaViewComponentCommand.php
@@ -60,20 +60,13 @@ private function makeData(ViewComponent $viewComponent): ImmutableArray
private function resolveViewComponent(string $viewComponent): ?ViewComponent
{
if (is_file($viewComponent)) {
- foreach ($this->viewConfig->viewComponents as $registeredViewComponent) {
- if ($registeredViewComponent->file !== $viewComponent) {
- continue;
- }
-
- $viewComponent = $registeredViewComponent;
-
- break;
- }
- } else {
- $viewComponent = $this->viewConfig->viewComponents[$viewComponent] ?? null;
+ return array_find(
+ array: $this->viewConfig->viewComponents,
+ callback: fn ($registeredViewComponent) => $registeredViewComponent->file === $viewComponent,
+ );
}
- return $viewComponent;
+ return $this->viewConfig->viewComponents[$viewComponent] ?? null;
}
private function resolveSlots(ViewComponent $viewComponent): ImmutableArray
diff --git a/src/Tempest/Framework/Testing/IntegrationTest.php b/src/Tempest/Framework/Testing/IntegrationTest.php
index 08ea0e93d4..fb085ac682 100644
--- a/src/Tempest/Framework/Testing/IntegrationTest.php
+++ b/src/Tempest/Framework/Testing/IntegrationTest.php
@@ -16,7 +16,6 @@
use Tempest\Console\OutputBuffer;
use Tempest\Console\Testing\ConsoleTester;
use Tempest\Container\GenericContainer;
-use Tempest\Core\AppConfig;
use Tempest\Core\ExceptionTester;
use Tempest\Core\FrameworkKernel;
use Tempest\Core\Kernel;
@@ -52,8 +51,6 @@ abstract class IntegrationTest extends TestCase
/** @var \Tempest\Discovery\DiscoveryLocation[] */
protected array $discoveryLocations = [];
- protected AppConfig $appConfig;
-
protected Kernel $kernel;
protected GenericContainer $container;
@@ -127,8 +124,6 @@ protected function setupKernel(): self
$container = $this->kernel->container;
$this->container = $container;
- $this->appConfig = $this->container->get(className: AppConfig::class);
-
return $this;
}
@@ -228,8 +223,6 @@ protected function tearDown(): void
/** @phpstan-ignore-next-line */
unset($this->discoveryLocations);
/** @phpstan-ignore-next-line */
- unset($this->appConfig);
- /** @phpstan-ignore-next-line */
unset($this->kernel);
/** @phpstan-ignore-next-line */
unset($this->container);
diff --git a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php
index 1037817627..87279d839b 100644
--- a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php
+++ b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php
@@ -5,6 +5,7 @@
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Tempest\Auth\Authentication\Authenticatable;
+use Tempest\Auth\Exceptions\OAuthStateWasInvalid;
use Tempest\Auth\OAuth\Config\GitHubOAuthConfig;
use Tempest\Auth\OAuth\OAuthClient;
use Tempest\Auth\OAuth\OAuthUser;
@@ -263,6 +264,69 @@ public function abstracted_flow(): void
$this->assertEquals('Jane Developer', $user->full_name);
$this->assertEquals('janedev', $user->username);
}
+
+ #[Test]
+ public function state_is_cleared_after_authentication(): void
+ {
+ $this->database->reset(migrate: false);
+ $this->database->migrate(CreateMigrationsTable::class, CreateUsersTable::class);
+
+ $this->container->config(new GitHubOAuthConfig(
+ clientId: 'foo',
+ clientSecret: 'bar', // @mago-expect lint:no-literal-password
+ redirectTo: '/oauth/github',
+ ));
+
+ $client = $this->oauth->fake($this->user);
+
+ $client->createRedirect();
+
+ $this->assertNotNull($client->getState());
+
+ $request = new GenericRequest(
+ method: Method::GET,
+ uri: Uri\set_query('/oauth/callback', code: 'auth-code', state: $client->getState()),
+ );
+
+ $client->authenticate(
+ $request,
+ static fn (OAuthUser $user): User => query(User::class)->updateOrCreate(
+ ['github_id' => $user->id],
+ ['email' => $user->email ?? '', 'full_name' => $user->name ?? '', 'username' => $user->nickname ?? ''],
+ ),
+ );
+
+ $this->assertNull($client->getState());
+ }
+
+ #[Test]
+ public function state_is_cleared_even_when_validation_fails(): void
+ {
+ $this->container->config(new GitHubOAuthConfig(
+ clientId: 'foo',
+ clientSecret: 'bar', // @mago-expect lint:no-literal-password
+ redirectTo: '/oauth/github',
+ ));
+
+ $client = $this->oauth->fake($this->user);
+
+ $client->createRedirect();
+
+ $this->assertNotNull($client->getState());
+
+ $request = new GenericRequest(
+ method: Method::GET,
+ uri: Uri\set_query('/oauth/callback', code: 'auth-code', state: 'invalid-state'),
+ );
+
+ try {
+ $client->authenticate($request, static fn (OAuthUser $user): User => new User('', '', '', ''));
+ } catch (OAuthStateWasInvalid) {
+ // @mago-expect lint:no-empty-catch-clause
+ }
+
+ $this->assertNull($client->getState());
+ }
}
final class User implements Authenticatable
diff --git a/tests/Integration/Console/Commands/CompleteCommandTest.php b/tests/Integration/Console/Commands/CompleteCommandTest.php
index 12462a380f..a61125e253 100644
--- a/tests/Integration/Console/Commands/CompleteCommandTest.php
+++ b/tests/Integration/Console/Commands/CompleteCommandTest.php
@@ -4,6 +4,7 @@
namespace Tests\Tempest\Integration\Console\Commands;
+use PHPUnit\Framework\Attributes\Test;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
/**
@@ -11,20 +12,22 @@
*/
final class CompleteCommandTest extends FrameworkIntegrationTestCase
{
- public function test_complete_commands(): void
+ #[Test]
+ public function complete_commands(): void
{
$this->console
->complete()
- ->assertSee('tail:server' . PHP_EOL)
+ ->assertSee('migrate:up' . PHP_EOL)
->assertSee('schedule:run' . PHP_EOL);
}
- public function test_complete_arguments(): void
+ #[Test]
+ public function complete_arguments(): void
{
$this->console
- ->complete('tail:')
- ->assertSee('tail:server' . PHP_EOL)
- ->assertSee('tail:project' . PHP_EOL)
- ->assertSee('tail:debug' . PHP_EOL);
+ ->complete('migrate:')
+ ->assertSee('migrate:down' . PHP_EOL)
+ ->assertSee('migrate:up' . PHP_EOL)
+ ->assertSee('migrate:rehash' . PHP_EOL);
}
}
diff --git a/tests/Integration/Log/GenericLoggerTest.php b/tests/Integration/Log/GenericLoggerTest.php
index 8120b8707c..ffcab0b465 100644
--- a/tests/Integration/Log/GenericLoggerTest.php
+++ b/tests/Integration/Log/GenericLoggerTest.php
@@ -6,16 +6,24 @@
use Monolog\Level;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\PostCondition;
+use PHPUnit\Framework\Attributes\PreCondition;
+use PHPUnit\Framework\Attributes\Test;
use Psr\Log\LogLevel as PsrLogLevel;
use ReflectionClass;
+use Tempest\Core\AppConfig;
+use Tempest\DateTime\Duration;
use Tempest\EventBus\EventBus;
use Tempest\Log\Channels\AppendLogChannel;
-use Tempest\Log\Channels\DailyLogChannel;
-use Tempest\Log\Channels\WeeklyLogChannel;
+use Tempest\Log\Config\DailyLogConfig;
+use Tempest\Log\Config\MultipleChannelsLogConfig;
+use Tempest\Log\Config\NullLogConfig;
+use Tempest\Log\Config\SimpleLogConfig;
+use Tempest\Log\Config\WeeklyLogConfig;
use Tempest\Log\GenericLogger;
-use Tempest\Log\LogConfig;
use Tempest\Log\LogLevel;
use Tempest\Log\MessageLogged;
+use Tempest\Support\Filesystem;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
/**
@@ -23,118 +31,121 @@
*/
final class GenericLoggerTest extends FrameworkIntegrationTestCase
{
- public function test_append_log_channel_works(): void
- {
- $filePath = __DIR__ . '/logs/tempest.log';
-
- $config = new LogConfig(
- channels: [
- new AppendLogChannel($filePath),
- ],
- );
-
- $logger = new GenericLogger($config, $this->container->get(EventBus::class));
+ private EventBus $bus {
+ get => $this->container->get(EventBus::class);
+ }
- $logger->info('test');
+ private AppConfig $appConfig {
+ get => $this->container->get(AppConfig::class);
+ }
- $this->assertFileExists($filePath);
+ #[PreCondition]
+ protected function configure(): void
+ {
+ Filesystem\ensure_directory_empty(__DIR__ . '/logs');
+ }
- $this->assertStringContainsString('test', file_get_contents($filePath));
+ #[PostCondition]
+ protected function cleanup(): void
+ {
+ Filesystem\delete_directory(__DIR__ . '/logs');
}
- protected function tearDown(): void
+ #[Test]
+ public function simple_log_config(): void
{
- $files = glob(__DIR__ . '/logs/*.log');
+ $filePath = __DIR__ . '/logs/tempest.log';
- foreach ($files as $file) {
- if (is_file($file)) {
- unlink($file);
- }
- }
+ $config = new SimpleLogConfig($filePath, prefix: 'tempest');
+
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
+ $logger->info('test');
+
+ $this->assertFileExists($filePath);
+ $this->assertStringContainsString('test', Filesystem\read_file($filePath));
}
- public function test_daily_log_channel_works(): void
+ #[Test]
+ public function daily_log_config(): void
{
+ $clock = $this->clock();
$filePath = __DIR__ . '/logs/tempest-' . date('Y-m-d') . '.log';
+ $config = new DailyLogConfig(__DIR__ . '/logs/tempest.log', prefix: 'tempest');
- $config = new LogConfig(
- channels: [
- new DailyLogChannel(__DIR__ . '/logs/tempest.log'),
- ],
- );
-
- $logger = new GenericLogger($config, $this->container->get(EventBus::class));
-
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
$logger->info('test');
$this->assertFileExists($filePath);
+ $this->assertStringContainsString('test', Filesystem\read_file($filePath));
+
+ $clock->plus(Duration::day());
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
+ $logger->info('test');
- $this->assertStringContainsString('test', file_get_contents($filePath));
+ $clock->plus(Duration::days(2));
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
+ $logger->info('test');
}
- public function test_weekly_log_channel_works(): void
+ #[Test]
+ public function weekly_log_config(): void
{
$filePath = __DIR__ . '/logs/tempest-' . date('Y-W') . '.log';
+ $config = new WeeklyLogConfig(__DIR__ . '/logs/tempest.log', prefix: 'tempest');
- $config = new LogConfig(
- channels: [
- new WeeklyLogChannel(__DIR__ . '/logs/tempest.log'),
- ],
- );
-
- $logger = new GenericLogger($config, $this->container->get(EventBus::class));
-
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
$logger->info('test');
$this->assertFileExists($filePath);
-
- $this->assertStringContainsString('test', file_get_contents($filePath));
+ $this->assertStringContainsString('test', Filesystem\read_file($filePath));
}
- public function test_multiple_same_log_channels_works(): void
+ #[Test]
+ public function multiple_same_log_channels(): void
{
$filePath = __DIR__ . '/logs/multiple-tempest1.log';
$secondFilePath = __DIR__ . '/logs/multiple-tempest2.log';
- $config = new LogConfig(
+ $config = new MultipleChannelsLogConfig(
channels: [
new AppendLogChannel($filePath),
new AppendLogChannel($secondFilePath),
],
+ prefix: 'tempest',
);
- $logger = new GenericLogger($config, $this->container->get(EventBus::class));
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
$logger->info('test');
$this->assertFileExists($filePath);
- $this->assertStringContainsString('test', file_get_contents($filePath));
+ $this->assertStringContainsString('test', Filesystem\read_file($filePath));
$this->assertFileExists($secondFilePath);
- $this->assertStringContainsString('test', file_get_contents($secondFilePath));
+ $this->assertStringContainsString('test', Filesystem\read_file($secondFilePath));
}
+ #[Test]
#[DataProvider('psrLogLevelProvider')]
#[DataProvider('monologLevelProvider')]
#[DataProvider('tempestLevelProvider')]
- public function test_log_levels(mixed $level, string $expected): void
+ public function log_levels(mixed $level, string $expected): void
{
$filePath = __DIR__ . '/logs/tempest.log';
- $config = new LogConfig(
+ $config = new SimpleLogConfig(
+ path: $filePath,
prefix: 'tempest',
- channels: [
- new AppendLogChannel($filePath),
- ],
);
- $logger = new GenericLogger($config, $this->container->get(EventBus::class));
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
$logger->log($level, 'test');
$this->assertFileExists($filePath);
- $this->assertStringContainsString('tempest.' . $expected, file_get_contents($filePath));
+ $this->assertStringContainsString('tempest.' . $expected, Filesystem\read_file($filePath));
}
+ #[Test]
#[DataProvider('tempestLevelProvider')]
- public function test_message_logged_emitted(LogLevel $level, string $_expected): void
+ public function message_logged_emitted(LogLevel $level, string $_expected): void
{
$eventBus = $this->container->get(EventBus::class);
@@ -144,28 +155,26 @@ public function test_message_logged_emitted(LogLevel $level, string $_expected):
$this->assertSame(['foo' => 'bar'], $event->context);
});
- $logger = new GenericLogger(new LogConfig(), $eventBus);
+ $logger = new GenericLogger(new NullLogConfig(), $this->appConfig, $this->bus);
$logger->log($level, 'This is a log message of level: ' . $level->value, context: ['foo' => 'bar']);
}
- public function test_different_log_levels_works(): void
+ #[Test]
+ public function different_log_levels(): void
{
$filePath = __DIR__ . '/logs/tempest.log';
- $config = new LogConfig(
+ $config = new SimpleLogConfig(
+ path: $filePath,
prefix: 'tempest',
- channels: [
- new AppendLogChannel($filePath),
- ],
);
- $logger = new GenericLogger($config, $this->container->get(EventBus::class));
+ $logger = new GenericLogger($config, $this->appConfig, $this->bus);
$logger->critical('critical');
$logger->debug('debug');
$this->assertFileExists($filePath);
- $content = file_get_contents($filePath);
- $this->assertStringContainsString('critical', $content);
- $this->assertStringContainsString('debug', $content);
+ $this->assertStringContainsString('critical', Filesystem\read_file($filePath));
+ $this->assertStringContainsString('debug', Filesystem\read_file($filePath));
}
public static function tempestLevelProvider(): array
diff --git a/tests/Integration/Log/LogConfigTest.php b/tests/Integration/Log/LogConfigTest.php
deleted file mode 100644
index ada19f1a1d..0000000000
--- a/tests/Integration/Log/LogConfigTest.php
+++ /dev/null
@@ -1,23 +0,0 @@
-container->get(LogConfig::class);
-
- $this->assertSame(root_path('log/debug.log'), $logConfig->debugLogPath);
- }
-}