Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/nbproject/private/
/vendor/
/.env

Engine/storage/views/

Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

// Define application constants
use Shift\App;
use Shift\Config\EnvLoader;
use Shift\Error\ErrorHandler;

define('APP_ROOT', realpath(__DIR__ . '/'));
Expand All @@ -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']);

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
41 changes: 40 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ <h2>Contents</h2>
<li><a href="#validation">Validation and DTOs</a></li>
<li><a href="#middleware">Middleware</a></li>
<li><a href="#services">Service Container</a></li>
<li><a href="#database">Environment and Database</a></li>
<li><a href="#cli">CLI</a></li>
<li><a href="#errors">Errors</a></li>
<li><a href="#testing">Testing</a></li>
Expand All @@ -38,6 +39,7 @@ <h2>Requirements</h2>
<li>PHP 8.3 or newer</li>
<li>Composer</li>
<li>The <code>json</code> PHP extension</li>
<li>The <code>pdo</code> PHP extension</li>
</ul>
</section>

Expand Down Expand Up @@ -71,6 +73,10 @@ <h3>Core Namespaces</h3>
<dd>Module contracts and module loader.</dd>
<dt><code>Shift\Service</code></dt>
<dd>Small service container and service interface.</dd>
<dt><code>Shift\Config</code></dt>
<dd>Environment variable loading and lookup.</dd>
<dt><code>Shift\Database</code></dt>
<dd>PDO database configuration, connection, and query helpers.</dd>
<dt><code>Shift\Console</code></dt>
<dd>CLI command dispatcher and built-in commands.</dd>
<dt><code>Shift\Error</code></dt>
Expand Down Expand Up @@ -383,7 +389,40 @@ <h2>Service Container</h2>
$controller = $container-&gt;make(HealthController::class);
$exists = $container-&gt;has(HealthService::class);</code></pre>

<p>The app registers the current request and router as default singleton services under <code>request</code>, <code>Shift\Request</code>, <code>router</code>, and <code>Shift\Routing\Router\Router</code>. It also registers the current <code>Shift\Service\ServiceContainer</code> instance.</p>
<p>The app registers the current request and router as default singleton services under <code>request</code>, <code>Shift\Request</code>, <code>router</code>, and <code>Shift\Routing\Router\Router</code>. It also registers the current <code>Shift\Service\ServiceContainer</code> instance, database config, and a lazy database service.</p>
</section>

<section id="database">
<h2>Environment and Database</h2>
<p><code>bootstrap.php</code> loads <code>.env</code> from the project root without overwriting variables that already exist in the server environment.</p>

<pre><code>DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=shift
DB_USERNAME=root
DB_PASSWORD=
DB_CHARSET=utf8mb4</code></pre>

<p>Database access uses native PDO. The app registers <code>Shift\Database\DatabaseConfig</code>, <code>Shift\Database\Database</code>, and the <code>db</code> alias lazily in the container.</p>

<pre><code>use Shift\Database\Database;

class UserService
{
public function __construct(private readonly Database $db)
{
}

public function find(int $id): ?array
{
return $this-&gt;db
-&gt;query('select * from users where id = :id', ['id' =&gt; $id])
-&gt;first();
}
}</code></pre>

<p>Use <code>query()</code> for prepared queries, <code>execute()</code> for write statements, <code>pdo()</code> for raw PDO access, and <code>transaction()</code> for transactional work.</p>
</section>

<section id="cli">
Expand Down
7 changes: 7 additions & 0 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/Config/Env.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Shift\Config;

final class Env
{
public static function get(string $key, mixed $default = null): mixed
{
if (array_key_exists($key, $_ENV)) {
return $_ENV[$key];
}

$value = getenv($key);

if ($value !== false) {
return $value;
}

return $default;
}

public static function has(string $key): bool
{
return self::get($key) !== null;
}
}
68 changes: 68 additions & 0 deletions src/Config/EnvLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Shift\Config;

final class EnvLoader
{
public function load(string $path, bool $overwrite = false): void
{
if (!is_file($path)) {
return;
}

$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

if ($lines === false) {
return;
}

foreach ($lines as $line) {
$line = trim($line);

if ($line === '' || str_starts_with($line, '#')) {
continue;
}

if (str_starts_with($line, 'export ')) {
$line = trim(substr($line, 7));
}

if (!str_contains($line, '=')) {
continue;
}

[$key, $value] = explode('=', $line, 2);
$key = trim($key);

if ($key === '' || (!$overwrite && Env::has($key))) {
continue;
}

$this->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);
}
}
82 changes: 82 additions & 0 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Shift\Database;

use PDO;
use PDOException;

final class Database
{
private ?PDO $pdo = null;

public function __construct(private readonly DatabaseConfig $config)
{
}

public function pdo(): PDO
{
if ($this->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,
];
}
}
Loading
Loading