diff --git a/.env.example b/.env.example
index 0099258..647f030 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,8 @@
APP_ENV=local
+LOG_ENABLED=false
+LOG_PATH=storage/logs/shift.log
+
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
diff --git a/.gitignore b/.gitignore
index 93a8507..09e3623 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,7 @@
Engine/storage/views/
storage/cache/*
!storage/cache/.gitkeep
+storage/logs/*
+!storage/logs/.gitkeep
\.idea/
diff --git a/README.md b/README.md
index d090230..22a31b1 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,9 @@ Built-in middleware includes `Shift\Middleware\CorsMiddleware`, `Shift\Middlewar
ShiftPHP loads `.env` from the project root during bootstrap. Use `.env.example` as the starting point:
```env
+APP_ENV=local
+LOG_ENABLED=false
+LOG_PATH=storage/logs/shift.log
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
@@ -245,6 +248,25 @@ DB_CHARSET=utf8mb4
Existing server environment variables are not overwritten by `.env`.
+## Logging
+
+Structured exception logging is available through `Shift\Logging\LoggerInterface`. It uses a no-op logger by default and writes JSON lines when logging is enabled:
+
+```env
+LOG_ENABLED=true
+LOG_PATH=storage/logs/shift.log
+```
+
+Each log record contains `timestamp`, `level`, `message`, and `context`. Exception context includes the exception class, status code, file, line, and request data such as method, path, IP, user agent, and `X-Request-Id` when present.
+
+You can replace the logger through the service container:
+
+```php
+use Shift\Logging\LoggerInterface;
+
+$app->getContainer()->singleton(LoggerInterface::class, new CustomLogger());
+```
+
## Database
Database access uses native PDO and is registered lazily in the service container as `Shift\Database\Database` and `db`:
diff --git a/REFACTORING.md b/REFACTORING.md
index 3640504..b262977 100644
--- a/REFACTORING.md
+++ b/REFACTORING.md
@@ -35,6 +35,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled
- [x] Centralized CLI command registry shared by the dispatcher and help command.
- [x] Database migrations with create, migrate, status, and rollback commands.
- [x] Module discovery cache for production.
+- [x] Structured exception logging with JSON file logger and service container override.
- [x] Removal of view storage and example page assets from runtime.
- [x] Removal of legacy `application/controllers` and `application/routes.php`.
- [x] Domain-oriented framework namespaces:
@@ -160,6 +161,5 @@ Internal errors return a generic `500` message unless `display_errors` is enable
## Next
-- [ ] Structured logging for exceptions.
- [ ] CLI command aliases and richer command metadata.
- [ ] Basic package-quality checks, for example static analysis and coding style.
diff --git a/docs/index.html b/docs/index.html
index 689131f..a107b84 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -24,12 +24,13 @@
Contents
Responses
Validation and DTOs
Middleware
- Service Container
- Environment and Database
- Migrations
- CLI
- Errors
- Testing
+ Service Container
+ Environment and Database
+ Logging
+ Migrations
+ CLI
+ Errors
+ Testing
@@ -404,7 +405,9 @@ Environment and Database
DB_DATABASE=shift
DB_USERNAME=root
DB_PASSWORD=
-DB_CHARSET=utf8mb4
+DB_CHARSET=utf8mb4
+LOG_ENABLED=false
+LOG_PATH=storage/logs/shift.log
Database access uses native PDO. The app registers Shift\Database\DatabaseConfig, Shift\Database\Database, and the db alias lazily in the container.
@@ -471,6 +474,20 @@ Models
#[Guarded] fields are ignored during mass assignment through create() and query update(), but can be set explicitly before save(). Supported casts include int, float, bool, string, array, date, datetime, and class names. Class casts use fromArray() when available.
+
+ Logging
+ Structured exception logging is available through Shift\Logging\LoggerInterface. Logging is disabled by default and can be enabled with environment variables.
+
+ LOG_ENABLED=true
+LOG_PATH=storage/logs/shift.log
+
+ The file logger writes JSON lines with timestamp, level, message, and context. Exception context includes exception class, status code, file, line, and request data such as method, path, IP, user agent, and X-Request-Id when present.
+
+ use Shift\Logging\LoggerInterface;
+
+$app->getContainer()->singleton(LoggerInterface::class, new CustomLogger());
+
+
Migrations
Migration files live in database/migrations. Create a migration with the CLI:
diff --git a/src/App.php b/src/App.php
index 930a27c..73261b1 100755
--- a/src/App.php
+++ b/src/App.php
@@ -5,6 +5,9 @@
use Shift\Error\HttpError;
use Shift\Database\Database;
use Shift\Database\DatabaseConfig;
+use Shift\Logging\ExceptionLogger;
+use Shift\Logging\LoggerFactory;
+use Shift\Logging\LoggerInterface;
use Shift\Middleware\MiddlewareInterface;
use Shift\Middleware\MiddlewarePipeline;
use Shift\Response\JsonResponse;
@@ -54,6 +57,7 @@ public function start(): void
try {
$response = $this->handleRequest();
} catch (ValidationException $exception) {
+ $this->logException($exception, $exception->getStatusCode());
$response = JsonResponse::error(
$exception->getMessage(),
$exception->getStatusCode(),
@@ -61,6 +65,7 @@ public function start(): void
$exception->getHeaders()
);
} catch (HttpError $exception) {
+ $this->logException($exception, $exception->getStatusCode());
$response = JsonResponse::error(
$exception->getMessage(),
$exception->getStatusCode(),
@@ -68,6 +73,7 @@ public function start(): void
$exception->getHeaders()
);
} catch (Throwable $exception) {
+ $this->logException($exception, 500);
$response = JsonResponse::error('Internal Server Error', 500);
}
@@ -367,6 +373,7 @@ private function registerDefaultServices(): void
$container->resolve(DatabaseConfig::class)
));
$this->container->singleton('db', fn (ServiceContainer $container): Database => $container->resolve(Database::class));
+ $this->container->singleton(LoggerInterface::class, fn (): LoggerInterface => LoggerFactory::fromEnv());
}
public function getContainer(): ServiceContainer
@@ -383,4 +390,12 @@ public function resolve(string $service)
{
return $this->container->resolve($service);
}
+
+ private function logException(Throwable $exception, int $statusCode): void
+ {
+ try {
+ (new ExceptionLogger($this->container->resolve(LoggerInterface::class)))->log($exception, $this->request, $statusCode);
+ } catch (Throwable) {
+ }
+ }
}
diff --git a/src/Error/ErrorHandler.php b/src/Error/ErrorHandler.php
index 68b858a..96752d4 100644
--- a/src/Error/ErrorHandler.php
+++ b/src/Error/ErrorHandler.php
@@ -2,6 +2,7 @@
namespace Shift\Error;
+use Shift\Logging\ExceptionLogger;
use Throwable;
/**
@@ -48,6 +49,11 @@ public static function handleError(int $level, string $message, string $file = '
public static function handleException(Throwable $exception): void
{
+ try {
+ ExceptionLogger::default()->log($exception);
+ } catch (Throwable) {
+ }
+
if (self::$customHandler) {
call_user_func(self::$customHandler, $exception);
return;
diff --git a/src/Logging/ExceptionLogger.php b/src/Logging/ExceptionLogger.php
new file mode 100644
index 0000000..eeaf683
--- /dev/null
+++ b/src/Logging/ExceptionLogger.php
@@ -0,0 +1,45 @@
+getStatusCode() : 500;
+ $level = $statusCode >= 500 ? LogLevel::ERROR : LogLevel::WARNING;
+
+ $context = [
+ 'exception' => $exception::class,
+ 'status' => $statusCode,
+ 'code' => $exception->getCode(),
+ 'file' => $exception->getFile(),
+ 'line' => $exception->getLine(),
+ ];
+
+ if ($request instanceof Request) {
+ $context['request'] = [
+ 'method' => $request->getMethod(),
+ 'path' => $request->getPath(),
+ 'ip' => $request->getIpAddress(),
+ 'user_agent' => $request->getUserAgent(),
+ 'request_id' => $request->getHeader('X-Request-Id'),
+ ];
+ }
+
+ $this->logger->log($level, $exception->getMessage(), $context);
+ }
+}
diff --git a/src/Logging/JsonFileLogger.php b/src/Logging/JsonFileLogger.php
new file mode 100644
index 0000000..f704202
--- /dev/null
+++ b/src/Logging/JsonFileLogger.php
@@ -0,0 +1,40 @@
+path);
+
+ if (!is_dir($directory)) {
+ mkdir($directory, 0775, true);
+ }
+
+ $record = [
+ 'timestamp' => date(DATE_ATOM),
+ 'level' => $level,
+ 'message' => $message,
+ 'context' => $context,
+ ];
+
+ try {
+ $line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL;
+ } catch (JsonException) {
+ $line = json_encode([
+ 'timestamp' => date(DATE_ATOM),
+ 'level' => LogLevel::ERROR,
+ 'message' => 'Log record could not be encoded.',
+ ], JSON_THROW_ON_ERROR) . PHP_EOL;
+ }
+
+ file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX);
+ }
+}
diff --git a/src/Logging/LogLevel.php b/src/Logging/LogLevel.php
new file mode 100644
index 0000000..812814b
--- /dev/null
+++ b/src/Logging/LogLevel.php
@@ -0,0 +1,11 @@
+headers, 'Header attribute should add response header.');
assertSameValue('Shift', $payload['name'] ?? null, 'Body attribute should bind JSON body key.');
},
+ 'app logs unhandled exceptions with request context' => function (): void {
+ $router = new Router();
+ (new AttributeRouteLoader())->load($router, [FailingController::class]);
+ $emitter = new CapturingEmitter();
+ $logger = new class implements LoggerInterface {
+ public array $records = [];
+
+ public function log(string $level, string $message, array $context = []): void
+ {
+ $this->records[] = compact('level', 'message', 'context');
+ }
+ };
+
+ $app = new App(makeRequest('GET', '/errors/boom'), $router, $emitter);
+ $app->getContainer()->singleton(LoggerInterface::class, $logger);
+ $app->start();
+
+ $payload = json_decode($emitter->content, true);
+ $record = $logger->records[0] ?? null;
+
+ assertSameValue(500, $emitter->statusCode, 'Unhandled exceptions should emit a 500 response.');
+ assertSameValue('Internal Server Error', $payload['error']['message'] ?? null, 'Unhandled exception details should not leak.');
+ assertSameValue('error', $record['level'] ?? null, 'Unhandled exceptions should be logged as errors.');
+ assertSameValue('Controller exploded', $record['message'] ?? null, 'Log message should contain the exception message.');
+ assertSameValue(RuntimeException::class, $record['context']['exception'] ?? null, 'Log context should include the exception class.');
+ assertSameValue('/errors/boom', $record['context']['request']['path'] ?? null, 'Log context should include request path.');
+ },
];
diff --git a/tests/Feature/LoggingTest.php b/tests/Feature/LoggingTest.php
new file mode 100644
index 0000000..bb348cb
--- /dev/null
+++ b/tests/Feature/LoggingTest.php
@@ -0,0 +1,29 @@
+ function (): void {
+ $root = sys_get_temp_dir() . '/shift-logs-' . bin2hex(random_bytes(6));
+ $path = $root . '/shift.log';
+
+ try {
+ (new JsonFileLogger($path))->log('info', 'Structured event', [
+ 'request' => [
+ 'path' => '/health',
+ ],
+ ]);
+
+ assertFileExists($path, 'Log file should be created.');
+
+ $lines = file($path, FILE_IGNORE_NEW_LINES);
+ $record = json_decode($lines[0] ?? '', true);
+
+ assertSameValue('info', $record['level'] ?? null, 'Log record should contain level.');
+ assertSameValue('Structured event', $record['message'] ?? null, 'Log record should contain message.');
+ assertSameValue('/health', $record['context']['request']['path'] ?? null, 'Log record should contain structured context.');
+ } finally {
+ removeDirectory($root);
+ }
+ },
+];
diff --git a/tests/Fixtures/TestControllers.php b/tests/Fixtures/TestControllers.php
index 6b1f7fd..f1946a1 100644
--- a/tests/Fixtures/TestControllers.php
+++ b/tests/Fixtures/TestControllers.php
@@ -168,3 +168,13 @@ public function authorize(AuthenticatedUser $user, Request $request, ?string $ab
return $ability === 'view';
}
}
+
+#[RoutePrefix('/errors')]
+final class FailingController extends Controller
+{
+ #[Get('/boom')]
+ public function boom(): array
+ {
+ throw new RuntimeException('Controller exploded');
+ }
+}