diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0099258
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+APP_ENV=local
+
+DB_CONNECTION=mysql
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_DATABASE=shift
+DB_USERNAME=root
+DB_PASSWORD=
+DB_CHARSET=utf8mb4
diff --git a/.gitignore b/.gitignore
index 2680b06..787214c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
/nbproject/private/
/vendor/
+/.env
Engine/storage/views/
diff --git a/README.md b/README.md
index e0e65a6..18827d7 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,8 @@ The current architecture focuses on an API-only modular monolith:
- controller actions,
- JSON responses,
- request helpers,
+- environment configuration,
+- PDO database queries,
- middleware pipeline,
- validation helpers and typed request DTOs,
- JSON error responses,
@@ -20,6 +22,7 @@ The current architecture focuses on an API-only modular monolith:
- PHP 8.3 or higher
- Composer
+- `json` and `pdo` PHP extensions
## Routing
@@ -226,6 +229,46 @@ $app->middleware(function (Request $request, callable $next): Response {
Built-in middleware includes `Shift\Middleware\CorsMiddleware`, `Shift\Middleware\AuthMiddleware`, and `Shift\Middleware\AuthorizationMiddleware`. Auth middleware uses `Shift\Auth\AuthenticatorInterface`; authorization middleware uses `Shift\Auth\AuthorizerInterface`.
+## Environment
+
+ShiftPHP loads `.env` from the project root during bootstrap. Use `.env.example` as the starting point:
+
+```env
+DB_CONNECTION=mysql
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_DATABASE=shift
+DB_USERNAME=root
+DB_PASSWORD=
+DB_CHARSET=utf8mb4
+```
+
+Existing server environment variables are not overwritten by `.env`.
+
+## Database
+
+Database access uses native PDO and is registered lazily in the service container as `Shift\Database\Database` and `db`:
+
+```php
+use Shift\Database\Database;
+
+class UserService
+{
+ public function __construct(private readonly Database $db)
+ {
+ }
+
+ public function find(int $id): ?array
+ {
+ return $this->db
+ ->query('select * from users where id = :id', ['id' => $id])
+ ->first();
+ }
+}
+```
+
+Available helpers are `query($sql, $parameters)`, `execute($sql, $parameters)`, `pdo()`, and `transaction($callback)`. Query results expose `all()`, `first()`, `value()`, `affectedRows()`, and the raw `PDOStatement`.
+
## Service Container
The container can resolve registered services and build classes with typed constructor dependencies:
diff --git a/REFACTORING.md b/REFACTORING.md
index 54ed3be..0642c4b 100644
--- a/REFACTORING.md
+++ b/REFACTORING.md
@@ -27,6 +27,8 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled
- [x] Module lifecycle hooks, for example `boot()` after service registration.
- [x] Framework source moved to `src/` for package split preparation.
- [x] CLI create generators for modules and module-owned classes with file-based stubs.
+- [x] `.env` loading for application configuration.
+- [x] Native PDO database configuration, lazy connection, and basic query API.
- [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:
diff --git a/bootstrap.php b/bootstrap.php
index 7b0a76b..9611cb6 100755
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -2,6 +2,7 @@
// Define application constants
use Shift\App;
+use Shift\Config\EnvLoader;
use Shift\Error\ErrorHandler;
define('APP_ROOT', realpath(__DIR__ . '/'));
@@ -12,6 +13,9 @@
// Load Composer autoloader
require_once VENDOR_PATH . '/autoload.php';
+// Load environment variables
+(new EnvLoader())->load(APP_ROOT . '/.env');
+
// Register custom autoloader
spl_autoload_register(['Shift\App', 'autoload']);
diff --git a/composer.json b/composer.json
index 3443953..52d5dfc 100644
--- a/composer.json
+++ b/composer.json
@@ -12,7 +12,8 @@
"minimum-stability": "dev",
"require": {
"php": ">=8.3",
- "ext-json": "*"
+ "ext-json": "*",
+ "ext-pdo": "*"
},
"scripts": {
"test": "XDEBUG_MODE=off php tests/ApiCoreTest.php"
diff --git a/docs/index.html b/docs/index.html
index 9439dd4..583ff63 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -25,6 +25,7 @@
Contents
Validation and DTOs
Middleware
Service Container
+ Environment and Database
CLI
Errors
Testing
@@ -38,6 +39,7 @@ Requirements
PHP 8.3 or newer
Composer
The json PHP extension
+ The pdo PHP extension
@@ -71,6 +73,10 @@ Core Namespaces
Module contracts and module loader.
Shift\Service
Small service container and service interface.
+ Shift\Config
+ Environment variable loading and lookup.
+ Shift\Database
+ PDO database configuration, connection, and query helpers.
Shift\Console
CLI command dispatcher and built-in commands.
Shift\Error
@@ -383,7 +389,40 @@ Service Container
$controller = $container->make(HealthController::class);
$exists = $container->has(HealthService::class);
- The app registers the current request and router as default singleton services under request, Shift\Request, router, and Shift\Routing\Router\Router. It also registers the current Shift\Service\ServiceContainer instance.
+ The app registers the current request and router as default singleton services under request, Shift\Request, router, and Shift\Routing\Router\Router. It also registers the current Shift\Service\ServiceContainer instance, database config, and a lazy database service.
+
+
+
+ Environment and Database
+ bootstrap.php loads .env from the project root without overwriting variables that already exist in the server environment.
+
+ DB_CONNECTION=mysql
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_DATABASE=shift
+DB_USERNAME=root
+DB_PASSWORD=
+DB_CHARSET=utf8mb4
+
+ Database access uses native PDO. The app registers Shift\Database\DatabaseConfig, Shift\Database\Database, and the db alias lazily in the container.
+
+ use Shift\Database\Database;
+
+class UserService
+{
+ public function __construct(private readonly Database $db)
+ {
+ }
+
+ public function find(int $id): ?array
+ {
+ return $this->db
+ ->query('select * from users where id = :id', ['id' => $id])
+ ->first();
+ }
+}
+
+ Use query() for prepared queries, execute() for write statements, pdo() for raw PDO access, and transaction() for transactional work.
diff --git a/src/App.php b/src/App.php
index 150577e..930a27c 100755
--- a/src/App.php
+++ b/src/App.php
@@ -3,6 +3,8 @@
namespace Shift;
use Shift\Error\HttpError;
+use Shift\Database\Database;
+use Shift\Database\DatabaseConfig;
use Shift\Middleware\MiddlewareInterface;
use Shift\Middleware\MiddlewarePipeline;
use Shift\Response\JsonResponse;
@@ -360,6 +362,11 @@ private function registerDefaultServices(): void
$this->container->singleton('router', $this->router);
$this->container->singleton(Router::class, $this->router);
$this->container->singleton(ServiceContainer::class, $this->container);
+ $this->container->singleton(DatabaseConfig::class, fn (): DatabaseConfig => DatabaseConfig::fromEnv());
+ $this->container->singleton(Database::class, fn (ServiceContainer $container): Database => new Database(
+ $container->resolve(DatabaseConfig::class)
+ ));
+ $this->container->singleton('db', fn (ServiceContainer $container): Database => $container->resolve(Database::class));
}
public function getContainer(): ServiceContainer
diff --git a/src/Config/Env.php b/src/Config/Env.php
new file mode 100644
index 0000000..9bfbd26
--- /dev/null
+++ b/src/Config/Env.php
@@ -0,0 +1,26 @@
+put($key, $this->parseValue(trim($value)));
+ }
+ }
+
+ private function put(string $key, string $value): void
+ {
+ $_ENV[$key] = $value;
+ $_SERVER[$key] = $value;
+ putenv($key . '=' . $value);
+ }
+
+ private function parseValue(string $value): string
+ {
+ if ($value === '') {
+ return '';
+ }
+
+ $quote = $value[0];
+
+ if (($quote === '"' || $quote === "'") && str_ends_with($value, $quote)) {
+ $value = substr($value, 1, -1);
+ } else {
+ $value = preg_replace('/\s+#.*$/', '', $value) ?? $value;
+ }
+
+ return str_replace(['\\n', '\\r'], ["\n", "\r"], $value);
+ }
+}
diff --git a/src/Database/Database.php b/src/Database/Database.php
new file mode 100644
index 0000000..b030b8e
--- /dev/null
+++ b/src/Database/Database.php
@@ -0,0 +1,82 @@
+pdo instanceof PDO) {
+ return $this->pdo;
+ }
+
+ try {
+ $this->pdo = new PDO(
+ $this->config->dsn(),
+ $this->config->username,
+ $this->config->password,
+ $this->options()
+ );
+ } catch (PDOException $exception) {
+ throw new DatabaseException('Database connection failed.', 0, $exception);
+ }
+
+ return $this->pdo;
+ }
+
+ public function query(string $sql, array $parameters = []): QueryResult
+ {
+ try {
+ $statement = $this->pdo()->prepare($sql);
+
+ if ($statement === false) {
+ throw new DatabaseException('Database query could not be prepared.');
+ }
+
+ $statement->execute($parameters);
+
+ return new QueryResult($statement);
+ } catch (PDOException $exception) {
+ throw new DatabaseException('Database query failed.', 0, $exception);
+ }
+ }
+
+ public function execute(string $sql, array $parameters = []): int
+ {
+ return $this->query($sql, $parameters)->affectedRows();
+ }
+
+ public function transaction(callable $callback): mixed
+ {
+ $pdo = $this->pdo();
+ $pdo->beginTransaction();
+
+ try {
+ $result = $callback($this);
+ $pdo->commit();
+
+ return $result;
+ } catch (\Throwable $exception) {
+ $pdo->rollBack();
+ throw $exception;
+ }
+ }
+
+ private function options(): array
+ {
+ return $this->config->options + [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ];
+ }
+}
diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php
new file mode 100644
index 0000000..cf38934
--- /dev/null
+++ b/src/Database/DatabaseConfig.php
@@ -0,0 +1,100 @@
+dsn !== null && $this->dsn !== '') {
+ return $this->dsn;
+ }
+
+ return match ($this->driver) {
+ 'sqlite' => 'sqlite:' . ($this->database !== '' ? $this->database : ':memory:'),
+ 'pgsql' => $this->pgsqlDsn(),
+ 'sqlsrv' => $this->sqlsrvDsn(),
+ default => $this->mysqlDsn(),
+ };
+ }
+
+ private static function intOrNull(mixed $value): ?int
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ return (int) $value;
+ }
+
+ private function mysqlDsn(): string
+ {
+ $parts = [
+ 'host=' . ($this->host ?? '127.0.0.1'),
+ 'dbname=' . $this->database,
+ 'charset=' . $this->charset,
+ ];
+
+ if ($this->port !== null) {
+ $parts[] = 'port=' . $this->port;
+ }
+
+ return 'mysql:' . implode(';', $parts);
+ }
+
+ private function pgsqlDsn(): string
+ {
+ $parts = [
+ 'host=' . ($this->host ?? '127.0.0.1'),
+ 'dbname=' . $this->database,
+ ];
+
+ if ($this->port !== null) {
+ $parts[] = 'port=' . $this->port;
+ }
+
+ return 'pgsql:' . implode(';', $parts);
+ }
+
+ private function sqlsrvDsn(): string
+ {
+ $server = $this->host ?? '127.0.0.1';
+
+ if ($this->port !== null) {
+ $server .= ',' . $this->port;
+ }
+
+ return 'sqlsrv:Server=' . $server . ';Database=' . $this->database;
+ }
+}
diff --git a/src/Database/DatabaseException.php b/src/Database/DatabaseException.php
new file mode 100644
index 0000000..dd3b718
--- /dev/null
+++ b/src/Database/DatabaseException.php
@@ -0,0 +1,9 @@
+statement->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ public function first(): ?array
+ {
+ $row = $this->statement->fetch(PDO::FETCH_ASSOC);
+
+ return $row === false ? null : $row;
+ }
+
+ public function value(string|int $column = 0): mixed
+ {
+ $row = $this->statement->fetch(PDO::FETCH_ASSOC);
+
+ if ($row === false) {
+ return null;
+ }
+
+ if (is_int($column)) {
+ return array_values($row)[$column] ?? null;
+ }
+
+ return $row[$column] ?? null;
+ }
+
+ public function affectedRows(): int
+ {
+ return $this->statement->rowCount();
+ }
+
+ public function statement(): PDOStatement
+ {
+ return $this->statement;
+ }
+}
diff --git a/tests/Feature/EnvAndDatabaseTest.php b/tests/Feature/EnvAndDatabaseTest.php
new file mode 100644
index 0000000..1d4b184
--- /dev/null
+++ b/tests/Feature/EnvAndDatabaseTest.php
@@ -0,0 +1,89 @@
+ function (): void {
+ $path = tempnam(sys_get_temp_dir(), 'shift-env-');
+ $key = 'SHIFT_TEST_ENV_' . bin2hex(random_bytes(4));
+ $quotedKey = $key . '_QUOTED';
+
+ try {
+ file_put_contents($path, "{$key}=plain\n{$quotedKey}=\"quoted value\"\n");
+
+ (new EnvLoader())->load($path);
+
+ assertSameValue('plain', Env::get($key), 'Env loader should expose plain values.');
+ assertSameValue('quoted value', Env::get($quotedKey), 'Env loader should unquote quoted values.');
+ } finally {
+ putenv($key);
+ putenv($quotedKey);
+ unset($_ENV[$key], $_ENV[$quotedKey], $_SERVER[$key], $_SERVER[$quotedKey]);
+
+ if (is_string($path) && is_file($path)) {
+ unlink($path);
+ }
+ }
+ },
+ 'database config builds dsn from environment' => function (): void {
+ $prefix = 'SHIFT_TEST_DB_' . bin2hex(random_bytes(4)) . '_';
+
+ try {
+ setTestEnv($prefix . 'CONNECTION', 'MySQL');
+ setTestEnv($prefix . 'HOST', 'db.local');
+ setTestEnv($prefix . 'PORT', '3307');
+ setTestEnv($prefix . 'DATABASE', 'shift_test');
+ setTestEnv($prefix . 'USERNAME', 'shift_user');
+ setTestEnv($prefix . 'PASSWORD', 'secret');
+
+ $config = DatabaseConfig::fromEnv($prefix);
+
+ assertSameValue('mysql:host=db.local;dbname=shift_test;charset=utf8mb4;port=3307', $config->dsn(), 'MySQL DSN should be built from env.');
+ assertSameValue('shift_user', $config->username, 'Username should be read from env.');
+ assertSameValue('secret', $config->password, 'Password should be read from env.');
+ } finally {
+ clearTestEnvPrefix($prefix);
+ }
+ },
+ 'database can execute simple parameterized queries' => function (): void {
+ $db = new Database(new DatabaseConfig(
+ driver: 'sqlite',
+ database: ':memory:'
+ ));
+
+ $db->execute('create table users (id integer primary key autoincrement, email text not null)');
+ $db->execute('insert into users (email) values (:email)', ['email' => 'dev@example.com']);
+
+ $row = $db->query('select id, email from users where email = :email', ['email' => 'dev@example.com'])->first();
+
+ assertSameValue('dev@example.com', $row['email'] ?? null, 'Database query should fetch inserted rows.');
+ },
+ 'app registers database services lazily' => function (): void {
+ $app = new App(makeRequest('GET', '/health'));
+
+ assertSameValue(true, $app->getContainer()->has(Database::class), 'App should register database service.');
+ assertSameValue(true, $app->getContainer()->has(DatabaseConfig::class), 'App should register database config service.');
+ assertSameValue(true, $app->getContainer()->has('db'), 'App should register db alias.');
+ },
+];
+
+function setTestEnv(string $key, string $value): void
+{
+ $_ENV[$key] = $value;
+ $_SERVER[$key] = $value;
+ putenv($key . '=' . $value);
+}
+
+function clearTestEnvPrefix(string $prefix): void
+{
+ foreach (array_keys($_ENV) as $key) {
+ if (str_starts_with($key, $prefix)) {
+ unset($_ENV[$key], $_SERVER[$key]);
+ putenv($key);
+ }
+ }
+}