Sfoglia il codice sorgente

Initial commit

master
Daniel Covington 1 settimana fa
commit
dae758b140
100 ha cambiato i file con 9291 aggiunte e 0 eliminazioni
  1. +13
    -0
      .env.example
  2. +5
    -0
      .gitignore
  3. +29
    -0
      Dockerfile
  4. +16
    -0
      app/Exceptions/Handler.php
  5. +19
    -0
      app/Http/Controllers/Products/OrderProductController.php
  6. +22
    -0
      app/Http/Controllers/Products/ShowProductController.php
  7. +46
    -0
      app/Http/Controllers/ShowHomePageController.php
  8. +27
    -0
      app/Http/Controllers/Users/LogInUserController.php
  9. +15
    -0
      app/Http/Controllers/Users/LogOutUserController.php
  10. +34
    -0
      app/Http/Controllers/Users/RegisterUserController.php
  11. +17
    -0
      app/Http/Controllers/Users/ShowRegisterFormController.php
  12. +15
    -0
      app/Models/Order.php
  13. +27
    -0
      app/Models/Product.php
  14. +15
    -0
      app/Models/Profile.php
  15. +20
    -0
      app/Models/User.php
  16. +11
    -0
      app/commands.php
  17. +51
    -0
      app/routes.php
  18. +17
    -0
      command.php
  19. +36
    -0
      composer.json
  20. +4886
    -0
      composer.lock
  21. +19
    -0
      config/cache.php
  22. +17
    -0
      config/database.php
  23. +13
    -0
      config/email.php
  24. +24
    -0
      config/filesystem.php
  25. +5
    -0
      config/handlers.php
  26. +11
    -0
      config/logging.php
  27. +17
    -0
      config/providers.php
  28. +10
    -0
      config/queue.php
  29. +9
    -0
      config/session.php
  30. +18
    -0
      database/migrations/001_CreateOrdersTable.php
  31. +13
    -0
      database/migrations/002_AddDeliveryInstructions.php
  32. +13
    -0
      database/migrations/003_ChangeQuantity.php
  33. +13
    -0
      database/migrations/004_DropPrice.php
  34. +15
    -0
      database/migrations/005_CreateProductsTable.php
  35. +31
    -0
      database/migrations/006_SeedProducts.php
  36. +16
    -0
      database/migrations/007_CreateUsersTable.php
  37. +14
    -0
      database/migrations/008_CreateProfilesTable.php
  38. +13
    -0
      database/migrations/009_AddUserId.php
  39. +17
    -0
      database/migrations/010_CreateJobsTable.php
  40. +43
    -0
      docker-compose.yml
  41. +23
    -0
      docker/nginx/default.conf
  42. +27
    -0
      docker/nginx/phpliteadmin.conf
  43. +3
    -0
      docker/php/app.ini
  44. +5
    -0
      docker/phpliteadmin/phpliteadmin.config.php
  45. +69
    -0
      docker/start-container.sh
  46. +822
    -0
      docs/framework.md
  47. +78
    -0
      framework/App.php
  48. +31
    -0
      framework/Cache/Driver/Driver.php
  49. +111
    -0
      framework/Cache/Driver/FileDriver.php
  50. +55
    -0
      framework/Cache/Driver/MemcacheDriver.php
  51. +54
    -0
      framework/Cache/Driver/MemoryDriver.php
  52. +9
    -0
      framework/Cache/Exception/DriverException.php
  53. +34
    -0
      framework/Cache/Factory.php
  54. +80
    -0
      framework/Container.php
  55. +111
    -0
      framework/Database/Command/MigrateCommand.php
  56. +45
    -0
      framework/Database/Connection/Connection.php
  57. +94
    -0
      framework/Database/Connection/MysqlConnection.php
  58. +69
    -0
      framework/Database/Connection/SqliteConnection.php
  59. +9
    -0
      framework/Database/Exception/ConnectionException.php
  60. +9
    -0
      framework/Database/Exception/MigrationException.php
  61. +9
    -0
      framework/Database/Exception/QueryException.php
  62. +34
    -0
      framework/Database/Factory.php
  63. +14
    -0
      framework/Database/Migration/Field/BoolField.php
  64. +14
    -0
      framework/Database/Migration/Field/DateTimeField.php
  65. +27
    -0
      framework/Database/Migration/Field/Field.php
  66. +14
    -0
      framework/Database/Migration/Field/FloatField.php
  67. +13
    -0
      framework/Database/Migration/Field/IdField.php
  68. +14
    -0
      framework/Database/Migration/Field/IntField.php
  69. +14
    -0
      framework/Database/Migration/Field/StringField.php
  70. +21
    -0
      framework/Database/Migration/Field/TextField.php
  71. +62
    -0
      framework/Database/Migration/Migration.php
  72. +164
    -0
      framework/Database/Migration/MysqlMigration.php
  73. +239
    -0
      framework/Database/Migration/SqliteMigration.php
  74. +189
    -0
      framework/Database/Model.php
  75. +56
    -0
      framework/Database/ModelCollector.php
  76. +15
    -0
      framework/Database/QueryBuilder/MysqlQueryBuilder.php
  77. +307
    -0
      framework/Database/QueryBuilder/QueryBuilder.php
  78. +15
    -0
      framework/Database/QueryBuilder/SqliteQueryBuilder.php
  79. +27
    -0
      framework/Database/Relationship.php
  80. +14
    -0
      framework/Database/TableName.php
  81. +12
    -0
      framework/Email/Driver/Driver.php
  82. +93
    -0
      framework/Email/Driver/PostmarkDriver.php
  83. +9
    -0
      framework/Email/Exception/CompositionException.php
  84. +9
    -0
      framework/Email/Exception/DriverException.php
  85. +34
    -0
      framework/Email/Factory.php
  86. +44
    -0
      framework/Filesystem/Driver/Driver.php
  87. +16
    -0
      framework/Filesystem/Driver/LocalDriver.php
  88. +9
    -0
      framework/Filesystem/Exception/DriverException.php
  89. +34
    -0
      framework/Filesystem/Factory.php
  90. +104
    -0
      framework/Http/Response.php
  91. +10
    -0
      framework/Logging/Driver/Driver.php
  92. +44
    -0
      framework/Logging/Driver/StreamDriver.php
  93. +9
    -0
      framework/Logging/Exception/DriverException.php
  94. +34
    -0
      framework/Logging/Factory.php
  95. +38
    -0
      framework/Provider/CacheProvider.php
  96. +16
    -0
      framework/Provider/ConfigProvider.php
  97. +34
    -0
      framework/Provider/DatabaseProvider.php
  98. +30
    -0
      framework/Provider/EmailProvider.php
  99. +30
    -0
      framework/Provider/FilesystemProvider.php
  100. +30
    -0
      framework/Provider/LoggingProvider.php

+ 13
- 0
.env.example Vedi File

@@ -0,0 +1,13 @@
APP_ENV=
APP_HOST=
APP_PORT=
DB_CONNECTION=sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pro-php-mvc
DB_USERNAME=root
DB_PASSWORD=

EMAIL_TOKEN=
EMAIL_FROM_NAME=
EMAIL_FROM_EMAIL=

+ 5
- 0
.gitignore Vedi File

@@ -0,0 +1,5 @@
.env
database/*.sqlite
drivers/*
vendor
*.cache

+ 29
- 0
Dockerfile Vedi File

@@ -0,0 +1,29 @@
FROM php:8.2-fpm-bookworm

RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx git unzip libsqlite3-dev \
&& docker-php-ext-install pdo_mysql pdo_sqlite \
&& rm -rf /var/lib/apt/lists/* \
&& rm -f /etc/nginx/conf.d/default.conf /etc/nginx/sites-enabled/default

COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

RUN git clone --depth 1 https://github.com/phpLiteAdmin/pla.git /var/www/phpliteadmin \
&& rm -rf /var/www/phpliteadmin/.git /var/www/phpliteadmin/docs

WORKDIR /var/www/html

COPY . /var/www/html
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY docker/nginx/phpliteadmin.conf /etc/nginx/conf.d/phpliteadmin.conf
COPY docker/php/app.ini /usr/local/etc/php/conf.d/app.ini
COPY docker/phpliteadmin/phpliteadmin.config.php /var/www/phpliteadmin/phpliteadmin.config.php
COPY docker/start-container.sh /usr/local/bin/start-container

RUN chmod +x /usr/local/bin/start-container \
&& chown -R www-data:www-data /var/www/phpliteadmin \
&& mkdir -p /run/php

EXPOSE 80

CMD ["start-container"]

+ 16
- 0
app/Exceptions/Handler.php Vedi File

@@ -0,0 +1,16 @@
<?php

namespace App\Exceptions;

use Framework\Support\ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
public function showThrowable(Throwable $throwable)
{
// add in some reporting...

return parent::showThrowable($throwable);
}
}

+ 19
- 0
app/Http/Controllers/Products/OrderProductController.php Vedi File

@@ -0,0 +1,19 @@
<?php

namespace App\Http\Controllers\Products;

use Framework\Routing\Router;

class OrderProductController
{
public function handle(Router $router)
{
secure();

// use $data to create a database record...

session()->put('ordered', true);

return redirect($router->route('show-home-page'));
}
}

+ 22
- 0
app/Http/Controllers/Products/ShowProductController.php Vedi File

@@ -0,0 +1,22 @@
<?php

namespace App\Http\Controllers\Products;

use App\Models\Product;
use Framework\Routing\Router;

class ShowProductController
{
public function handle(Router $router)
{
$parameters = $router->current()->parameters();

$product = Product::find((int) $parameters['product']);

return view('products/view', [
'product' => $product,
'orderAction' => $router->route('order-product', ['product' => $product->id]),
'csrf' => csrf(),
]);
}
}

+ 46
- 0
app/Http/Controllers/ShowHomePageController.php Vedi File

@@ -0,0 +1,46 @@
<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Framework\Routing\Router;

class ShowHomePageController
{
public function handle(Router $router)
{
$cache = app('cache');
$products = Product::all();

$productsWithRoutes = array_map(function ($product) use ($router, $cache) {
$key = "route-for-product-{$product->id}";

if (!$cache->has($key)) {
$cache->put($key, $router->route('view-product', ['product' => $product->id]));
}

$product->route = $cache->get($key);

return $product;
}, $products);

// app('queue')->push(
// fn($name) => app('logging')->info("Hello {$name}"),
// 'Chris',
// );

// app('logging')->info('Send a task into the background');

// app('queue')->push(
// fn($name) => app('email')
// ->to('cgpitt@gmail.com')
// ->text("Hello {$name}")
// ->send(),
// 'Chris',
// );

return view('home', [
'products' => $productsWithRoutes,
]);
}
}

+ 27
- 0
app/Http/Controllers/Users/LogInUserController.php Vedi File

@@ -0,0 +1,27 @@
<?php

namespace App\Http\Controllers\Users;

use App\Models\User;
use Framework\Routing\Router;

class LogInUserController
{
public function handle(Router $router)
{
secure();

$data = validate($_POST, [
'email' => ['required', 'email'],
'password' => ['required', 'min:10'],
], 'login_errors');

$user = User::where('email', $data['email'])->first();

if ($user && password_verify($data['password'], $user->password)) {
session()->put('user_id', $user->id);
}

return redirect($router->route('show-home-page'));
}
}

+ 15
- 0
app/Http/Controllers/Users/LogOutUserController.php Vedi File

@@ -0,0 +1,15 @@
<?php

namespace App\Http\Controllers\Users;

use Framework\Routing\Router;

class LogOutUserController
{
public function handle(Router $router)
{
session()->forget('user_id');

return redirect($router->route('show-home-page'));
}
}

+ 34
- 0
app/Http/Controllers/Users/RegisterUserController.php Vedi File

@@ -0,0 +1,34 @@
<?php

namespace App\Http\Controllers\Users;

use App\Models\User;
use Framework\Routing\Router;

class RegisterUserController
{
public function handle(Router $router)
{
secure();

$data = validate($_POST, [
'name' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'min:10'],
], 'register_errors');

$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = password_hash($data['password'], PASSWORD_BCRYPT);
$user->save();

session()->put('registered', true);

app('queue')->push(function($user) {
// send a mail to the user...
}, $user);

return redirect($router->route('show-home-page'));
}
}

+ 17
- 0
app/Http/Controllers/Users/ShowRegisterFormController.php Vedi File

@@ -0,0 +1,17 @@
<?php

namespace App\Http\Controllers\Users;

use Framework\Routing\Router;

class ShowRegisterFormController
{
public function handle(Router $router)
{
return view('users/register', [
'registerAction' => $router->route('register-user'),
'logInAction' => $router->route('log-in-user'),
'csrf' => csrf(),
]);
}
}

+ 15
- 0
app/Models/Order.php Vedi File

@@ -0,0 +1,15 @@
<?php

namespace App\Models;

use Framework\Database\Model;

class Order extends Model
{
protected string $table = 'orders';

public function user(): mixed
{
return $this->belongsTo(User::class, 'user_id');
}
}

+ 27
- 0
app/Models/Product.php Vedi File

@@ -0,0 +1,27 @@
<?php

namespace App\Models;

use Framework\Database\Model;

class Product extends Model
{
protected string $table = 'products';

public function getNameAttribute($value): string
{
return ucwords($value);
}

public function setDescriptionAttribute(string $value)
{
$limit = 50;
$ending = '...';

if (mb_strwidth($value, 'UTF-8') <= $limit) {
return $value;
}

return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')) . $ending;
}
}

+ 15
- 0
app/Models/Profile.php Vedi File

@@ -0,0 +1,15 @@
<?php

namespace App\Models;

use Framework\Database\Model;

class Profile extends Model
{
protected string $table = 'profiles';

public function user(): mixed
{
return $this->belongsTo(User::class, 'user_id');
}
}

+ 20
- 0
app/Models/User.php Vedi File

@@ -0,0 +1,20 @@
<?php

namespace App\Models;

use Framework\Database\TableName;
use Framework\Database\Model;

#[TableName('users')]
class User extends Model
{
public function profile(): mixed
{
return $this->hasOne(Profile::class, 'user_id');
}

public function orders(): mixed
{
return $this->hasMany(Order::class, 'user_id');
}
}

+ 11
- 0
app/commands.php Vedi File

@@ -0,0 +1,11 @@
<?php

use Framework\Database\Command\MigrateCommand;
use Framework\Support\Command\ServeCommand;
use Framework\Queue\Command\WorkCommand;

return [
MigrateCommand::class,
ServeCommand::class,
WorkCommand::class,
];

+ 51
- 0
app/routes.php Vedi File

@@ -0,0 +1,51 @@
<?php

use App\Http\Controllers\ShowHomePageController;
use App\Http\Controllers\Products\OrderProductController;
use App\Http\Controllers\Products\ShowProductController;
use App\Http\Controllers\Users\LogInUserController;
use App\Http\Controllers\Users\LogOutUserController;
use App\Http\Controllers\Users\RegisterUserController;
use App\Http\Controllers\Users\ShowRegisterFormController;
use Framework\Routing\Router;

return function(Router $router) {
$router->errorHandler(
404, fn() => 'whoops!'
);

$router->add(
'GET', '/',
[ShowHomePageController::class, 'handle'],
)->name('show-home-page');

$router->add(
'GET', '/products/view/{product}',
[ShowProductController::class, 'handle'],
)->name('view-product');

$router->add(
'POST', '/products/order/{product}',
[OrderProductController::class, 'handle'],
)->name('order-product');

$router->add(
'GET', '/register',
[ShowRegisterFormController::class, 'handle'],
)->name('show-register-form');

$router->add(
'POST', '/register',
[RegisterUserController::class, 'handle'],
)->name('register-user');

$router->add(
'POST', '/log-in',
[LogInUserController::class, 'handle'],
)->name('log-in-user');

$router->add(
'GET', '/log-out',
[LogOutUserController::class, 'handle'],
)->name('log-out-user');
};

+ 17
- 0
command.php Vedi File

@@ -0,0 +1,17 @@
<?php

require __DIR__ . '/vendor/autoload.php';

$app = \Framework\App::getInstance();
$app->bind('paths.base', fn() => __DIR__);
$app->prepare();

$console = new \Symfony\Component\Console\Application();

$commands = require __DIR__ . '/app/commands.php';

foreach ($commands as $command) {
$console->add(new $command);
}

$console->run();

+ 36
- 0
composer.json Vedi File

@@ -0,0 +1,36 @@
{
"name": "whoosh/website",
"description": "A website that sells Whoosh rockets",
"scripts": {
"serve": "php command.php serve",
"test": "vendor/bin/phpunit"
},
"autoload": {
"psr-4": {
"App\\": "app",
"Framework\\": "framework"
},
"files": [
"framework/helpers.php"
]
},
"config": {
"process-timeout": 0
},
"require": {
"filp/whoops": "^2.1",
"vlucas/phpdotenv": "^5.2",
"symfony/console": "^5.1",
"league/flysystem": "^2.0",
"opis/closure": "^3.6",
"monolog/monolog": "^2.2",
"swiftmailer/swiftmailer": "^6.2",
"wildbit/swiftmailer-postmark": "^3.3"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/panther": "^0.9.0",
"dbrekelmans/bdi": "^0.3.0",
"symfony/process": "^5.2"
}
}

+ 4886
- 0
composer.lock
File diff soppresso perché troppo grande
Vedi File


+ 19
- 0
config/cache.php Vedi File

@@ -0,0 +1,19 @@
<?php

return [
'default' => 'memory',
'memory' => [
'type' => 'memory',
'seconds' => 31536000,
],
'file' => [
'type' => 'file',
'seconds' => 31536000,
],
'memcache' => [
'type' => 'memcache',
'host' => '127.0.0.1',
'port' => 11211,
'seconds' => 31536000,
],
];

+ 17
- 0
config/database.php Vedi File

@@ -0,0 +1,17 @@
<?php

return [
'default' => env('DB_CONNECTION', 'sqlite'),
'mysql' => [
'type' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'pro-php-mvc'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
],
'sqlite' => [
'type' => 'sqlite',
'path' => env('DB_SQLITE_PATH', __DIR__ . '/../database/database.sqlite'),
],
];

+ 13
- 0
config/email.php Vedi File

@@ -0,0 +1,13 @@
<?php

return [
'default' => 'postmark',
'postmark' => [
'type' => 'postmark',
'token' => env('EMAIL_TOKEN'),
'from' => [
'name' => env('EMAIL_FROM_NAME'),
'email' => env('EMAIL_FROM_EMAIL'),
],
]
];

+ 24
- 0
config/filesystem.php Vedi File

@@ -0,0 +1,24 @@
<?php

return [
'default' => 'local',
'local' => [
'type' => 'local',
'path' => __DIR__ . '/../storage/app',
],
's3' => [
'type' => 's3',
'key' => '',
'secret' => '',
'token' => '',
'region' => '',
'bucket' => '',
],
'ftp' => [
'type' => 'ftp',
'host' => '',
'root' => '',
'username' => '',
'password' => '',
],
];

+ 5
- 0
config/handlers.php Vedi File

@@ -0,0 +1,5 @@
<?php

return [
'exceptions' => \App\Exceptions\Handler::class,
];

+ 11
- 0
config/logging.php Vedi File

@@ -0,0 +1,11 @@
<?php

return [
'default' => 'stream',
'stream' => [
'type' => 'stream',
'path' => __DIR__ . '/../storage/app.log',
'name' => 'App',
'minimum' => \Monolog\Logger::DEBUG,
],
];

+ 17
- 0
config/providers.php Vedi File

@@ -0,0 +1,17 @@
<?php

return [
// load config first, so the rest can use it...
\Framework\Provider\ConfigProvider::class,

\Framework\Provider\CacheProvider::class,
\Framework\Provider\DatabaseProvider::class,
\Framework\Provider\EmailProvider::class,
\Framework\Provider\FilesystemProvider::class,
\Framework\Provider\LoggingProvider::class,
\Framework\Provider\QueueProvider::class,
\Framework\Provider\ResponseProvider::class,
\Framework\Provider\SessionProvider::class,
\Framework\Provider\ValidationProvider::class,
\Framework\Provider\ViewProvider::class,
];

+ 10
- 0
config/queue.php Vedi File

@@ -0,0 +1,10 @@
<?php

return [
'default' => 'database',
'database' => [
'type' => 'database',
'table' => 'jobs',
'attempts' => 3,
],
];

+ 9
- 0
config/session.php Vedi File

@@ -0,0 +1,9 @@
<?php

return [
'default' => 'native',
'native' => [
'type' => 'native',
'prefix' => 'framework_',
],
];

+ 18
- 0
database/migrations/001_CreateOrdersTable.php Vedi File

@@ -0,0 +1,18 @@
<?php

use Framework\Database\Connection\Connection;

class CreateOrdersTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('orders');
$table->id('id');
$table->int('quantity')->default(1);
$table->float('price')->nullable();
$table->bool('is_confirmed')->default(false);
$table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP');
$table->text('notes');
$table->execute();
}
}

+ 13
- 0
database/migrations/002_AddDeliveryInstructions.php Vedi File

@@ -0,0 +1,13 @@
<?php

use Framework\Database\Connection\Connection;

class AddDeliveryInstructions
{
public function migrate(Connection $connection)
{
$table = $connection->alterTable('orders');
$table->text('delivery_instructions');
$table->execute();
}
}

+ 13
- 0
database/migrations/003_ChangeQuantity.php Vedi File

@@ -0,0 +1,13 @@
<?php

use Framework\Database\Connection\Connection;

class ChangeQuantity
{
public function migrate(Connection $connection)
{
$table = $connection->alterTable('orders');
$table->int('quantity')->nullable()->alter();
$table->execute();
}
}

+ 13
- 0
database/migrations/004_DropPrice.php Vedi File

@@ -0,0 +1,13 @@
<?php

use Framework\Database\Connection\Connection;

class DropPrice
{
public function migrate(Connection $connection)
{
$table = $connection->alterTable('orders');
$table->dropColumn('price');
$table->execute();
}
}

+ 15
- 0
database/migrations/005_CreateProductsTable.php Vedi File

@@ -0,0 +1,15 @@
<?php

use Framework\Database\Connection\Connection;

class CreateProductsTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('products');
$table->id('id');
$table->string('name');
$table->text('description');
$table->execute();
}
}

+ 31
- 0
database/migrations/006_SeedProducts.php Vedi File

@@ -0,0 +1,31 @@
<?php

use Framework\Database\Connection\Connection;

class SeedProducts
{
public function migrate(Connection $connection)
{
$products = [
[
'name' => 'Space Tour',
'description' => 'Take a trip on a rocket ship. Our tours are out of this world. Sign up now for a journey you won&apos;t soon forget.',
],
[
'name' => 'Large Rocket',
'description' => 'Need to bring some extra space-baggage? Everyone asking you to bring back a moon rock for them? This is the rocket you want...',
],
[
'name' => 'Small Rocket',
'description' => 'Space exploration is expensive. This rocket comes in under budget and atmosphere.',
],
];

foreach ($products as $product) {
$connection
->query()
->from('products')
->insert(['name', 'description'], $product);
}
}
}

+ 16
- 0
database/migrations/007_CreateUsersTable.php Vedi File

@@ -0,0 +1,16 @@
<?php

use Framework\Database\Connection\Connection;

class CreateUsersTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('users');
$table->id('id');
$table->string('name');
$table->string('email');
$table->string('password');
$table->execute();
}
}

+ 14
- 0
database/migrations/008_CreateProfilesTable.php Vedi File

@@ -0,0 +1,14 @@
<?php

use Framework\Database\Connection\Connection;

class CreateProfilesTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('profiles');
$table->id('id');
$table->int('user_id');
$table->execute();
}
}

+ 13
- 0
database/migrations/009_AddUserId.php Vedi File

@@ -0,0 +1,13 @@
<?php

use Framework\Database\Connection\Connection;

class AddUserId
{
public function migrate(Connection $connection)
{
$table = $connection->alterTable('orders');
$table->int('user_id');
$table->execute();
}
}

+ 17
- 0
database/migrations/010_CreateJobsTable.php Vedi File

@@ -0,0 +1,17 @@
<?php

use Framework\Database\Connection\Connection;

class CreateJobsTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('jobs');
$table->id('id');
$table->text('closure');
$table->text('params');
$table->int('attempts')->default(0);
$table->bool('is_complete')->default(false);
$table->execute();
}
}

+ 43
- 0
docker-compose.yml Vedi File

@@ -0,0 +1,43 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: pro-php-mvc-app
restart: unless-stopped
depends_on:
- db
ports:
- "8080:80"
- "8081:8081"
volumes:
- ./:/var/www/html
- vendor_data:/var/www/html/vendor
environment:
APP_ENV: dev
APP_HOST: 0.0.0.0
APP_PORT: 80
DB_CONNECTION: sqlite
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: pro-php-mvc
DB_USERNAME: root
DB_PASSWORD: root
COMPOSER_ALLOW_SUPERUSER: 1
PHPLITEADMIN_PASSWORD: admin

db:
image: mysql:8.0
container_name: pro-php-mvc-db
restart: unless-stopped
ports:
- "33060:3306"
environment:
MYSQL_DATABASE: pro-php-mvc
MYSQL_ROOT_PASSWORD: root
volumes:
- mysql_data:/var/lib/mysql

volumes:
mysql_data:
vendor_data:

+ 23
- 0
docker/nginx/default.conf Vedi File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;

root /var/www/html/public;
index index.php;

location / {
try_files $uri $uri/ /index.php?$query_string;
}

location ~ \.php$ {
include fastcgi_params;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass 127.0.0.1:9000;
}

location ~ /\. {
deny all;
}
}

+ 27
- 0
docker/nginx/phpliteadmin.conf Vedi File

@@ -0,0 +1,27 @@
server {
listen 8081;
server_name _;

root /var/www/phpliteadmin;
index index.php;

location / {
try_files $uri $uri/ /index.php?$query_string;
}

location ~ \.php$ {
include fastcgi_params;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
# fastcgi_params strips the port from HTTP_HOST for security; reattach
# nginx's own listening port (not the client-supplied one) so
# phpLiteAdmin's absolute redirects/links keep the :8081.
fastcgi_param HTTP_HOST $host:$server_port;
fastcgi_pass 127.0.0.1:9000;
}

location ~ /\. {
deny all;
}
}

+ 3
- 0
docker/php/app.ini Vedi File

@@ -0,0 +1,3 @@
error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED
display_errors = On
log_errors = On

+ 5
- 0
docker/phpliteadmin/phpliteadmin.config.php Vedi File

@@ -0,0 +1,5 @@
<?php

$password = getenv('PHPLITEADMIN_PASSWORD') ?: 'admin';
$directory = '/var/www/html/database';
$subdirectories = false;

+ 69
- 0
docker/start-container.sh Vedi File

@@ -0,0 +1,69 @@
#!/bin/sh
set -eu

cd /var/www/html

if [ ! -f .env ] && [ -f .env.example ]; then
cp .env.example .env
fi

if [ ! -f vendor/autoload.php ]; then
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
fi

if [ "${DB_CONNECTION:-mysql}" = "sqlite" ]; then
mkdir -p database
touch database/database.sqlite
chown www-data:www-data database/database.sqlite
chmod 664 database/database.sqlite

if ! php -r '
$pdo = new PDO("sqlite:database/database.sqlite");
$statement = $pdo->query("SELECT name FROM sqlite_master WHERE type='\''table'\'' AND name='\''migrations'\''");

exit($statement->fetch() ? 0 : 1);
'; then
php command.php migrate
fi
fi

if [ "${DB_CONNECTION:-mysql}" = "mysql" ]; then
php -r '
$host = getenv("DB_HOST") ?: "db";
$port = getenv("DB_PORT") ?: "3306";
$database = getenv("DB_DATABASE") ?: "pro-php-mvc";
$username = getenv("DB_USERNAME") ?: "root";
$password = getenv("DB_PASSWORD") ?: "";

for ($attempt = 0; $attempt < 30; $attempt++) {
try {
new PDO("mysql:host={$host};port={$port};dbname={$database}", $username, $password);
exit(0);
} catch (Throwable $exception) {
fwrite(STDERR, "Waiting for MySQL...\n");
sleep(2);
}
}

fwrite(STDERR, "MySQL did not become ready in time.\n");
exit(1);
';

if ! php -r '
$host = getenv("DB_HOST") ?: "db";
$port = getenv("DB_PORT") ?: "3306";
$database = getenv("DB_DATABASE") ?: "pro-php-mvc";
$username = getenv("DB_USERNAME") ?: "root";
$password = getenv("DB_PASSWORD") ?: "";

$pdo = new PDO("mysql:host={$host};port={$port};dbname={$database}", $username, $password);
$statement = $pdo->query("SHOW TABLES LIKE '\''migrations'\''");

exit($statement->fetch() ? 0 : 1);
'; then
php command.php migrate
fi
fi

php-fpm -D
exec nginx -g "daemon off;"

+ 822
- 0
docs/framework.md Vedi File

@@ -0,0 +1,822 @@
# Framework Guide

This is a small, hand-rolled MVC framework (`framework/`) that the application code (`app/`) is built on top of. It has no Laravel/Symfony-style magic beyond what's in this repo — every feature below is implemented in a few dozen lines you can go read directly (the file path is given for each piece, so you can always jump to the real source and see exactly what it does).

If you're new to this codebase, read **[Architecture](#architecture)** and then the **[tutorial](#tutorial-build-a-guestbook-feature)** first — it walks through building one small feature end-to-end and touches almost everything else in this guide along the way. Everything after that is reference material you can jump to as needed.

## Contents

- [Architecture](#architecture)
- [Tutorial: build a guestbook feature](#tutorial-build-a-guestbook-feature)
- [Bootstrapping & request lifecycle](#bootstrapping--request-lifecycle)
- [The container](#the-container)
- [Configuration](#configuration)
- [Routing](#routing)
- [Controllers](#controllers)
- [Views](#views)
- [Database: connections & query builder](#database-connections--query-builder)
- [Models & relationships](#models--relationships)
- [Migrations](#migrations)
- [Validation](#validation)
- [Sessions & CSRF](#sessions--csrf)
- [Caching](#caching)
- [Filesystem](#filesystem)
- [Email](#email)
- [Queue & background jobs](#queue--background-jobs)
- [Logging](#logging)
- [Error handling](#error-handling)
- [CLI commands](#cli-commands)
- [Testing](#testing)
- [Helper function reference](#helper-function-reference)

---

## Architecture

```
framework/ the framework itself, namespace Framework\...
app/ your application code, namespace App\...
Http/Controllers/ one class per route handler
Models/ Framework\Database\Model subclasses
routes.php route definitions
commands.php CLI commands to register
config/ one file per subsystem, each returns an array
database/migrations/ numbered migration files
resources/views/ templates (four possible engines, see below)
public/index.php the web entry point
command.php the CLI entry point
```

Everything is wired together through **providers** (`framework/Provider/*`, listed in `config/providers.php`). Each provider's `bind()` method registers one or more services into the container. `Framework\Support\DriverProvider` is a base class used by most of them (database, cache, session, filesystem, email, queue, logging) — it reads `config/<name>.php`, picks the driver named by `'default'`, and binds it under the container alias `<name>` (e.g. `app('cache')`, `app('database')`).

A request touches these pieces roughly in order:

```
browser request
public/index.php ─── boots the app, then calls run()
Router (app/routes.php) ─── finds the matching route by method + path
your Controller ─── the route's handler method runs
│ (reads input, talks to Models, calls validate()/csrf helpers)
a View (optional) ─── renders HTML using data the controller passed in
Response ─── sent back to the browser (HTML, JSON, or a redirect)
```

The tutorial below walks through exactly this path for a real feature, top to bottom.

## Tutorial: build a guestbook feature

This builds a tiny "leave a message" page from scratch: a form at `/guestbook` where visitors type a name and a message, a list of everything anyone's posted, and validation on the form. It uses a migration, a model, a route, two controllers, and a view — i.e. almost everything in this guide, in the order you'd actually touch them.

### 1. Add a database table (a migration)

Create `database/migrations/011_CreateMessagesTable.php` (the leading number just has to sort after the existing files):

```php
<?php

use Framework\Database\Connection\Connection;

class CreateMessagesTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('messages');
$table->id('id');
$table->string('name');
$table->string('body');
$table->dateTime('created_at')->default('CURRENT_TIMESTAMP');
$table->execute();
}
}
```

Run it:

```
php command.php migrate
```

You should see `Migrating: CreateMessagesTable` printed, and a new `messages` table will exist (in `database/database.sqlite` by default). See [Migrations](#migrations) for the full field-type list and how to alter/drop columns later.

### 2. Add a model

Create `app/Models/Message.php`. This is what lets you write `Message::all()` and `$message->save()` instead of hand-writing SQL:

```php
<?php

namespace App\Models;

use Framework\Database\Model;

class Message extends Model
{
protected string $table = 'messages';
}
```

That's the whole class — table name is the only thing it *needs* to know. See [Models & relationships](#models--relationships) for accessors, casts, and relationships you can add later.

### 3. Add routes

Open `app/routes.php` and add two routes: one to show the page, one to handle the form submission.

```php
use App\Http\Controllers\Guestbook\ShowGuestbookController;
use App\Http\Controllers\Guestbook\PostGuestbookController;

$router->add('GET', '/guestbook', [ShowGuestbookController::class, 'handle'])
->name('show-guestbook');

$router->add('POST', '/guestbook', [PostGuestbookController::class, 'handle'])
->name('post-guestbook');
```

Following the existing app's convention, every route points at its own small controller class (one action per class) rather than one big controller with many methods. See [Routing](#routing) for route parameters (`{id}`) and named-route generation.

### 4. Add the controllers

`app/Http/Controllers/Guestbook/ShowGuestbookController.php` — fetches all messages and renders the view:

```php
<?php

namespace App\Http\Controllers\Guestbook;

use App\Models\Message;

class ShowGuestbookController
{
public function handle()
{
return view('guestbook', [
'messages' => Message::all(),
'csrf' => csrf(),
]);
}
}
```

`app/Http/Controllers/Guestbook/PostGuestbookController.php` — validates the form and saves a new message:

```php
<?php

namespace App\Http\Controllers\Guestbook;

use App\Models\Message;
use Framework\Routing\Router;

class PostGuestbookController
{
public function handle(Router $router)
{
secure(); // reject the request if $_POST['csrf'] doesn't match the session token

$data = validate($_POST, [
'name' => ['required'],
'body' => ['required', 'min:3'],
], 'guestbook_errors');

$message = new Message();
$message->name = $data['name'];
$message->body = $data['body'];
$message->save();

return redirect($router->route('show-guestbook'));
}
}
```

A few things worth noticing here, because they trip people up the first time:

- `handle()` only takes a `Router $router` parameter *when it needs one* — the container inspects the method signature and only supplies what's type-hinted (see [The container](#the-container)). `ShowGuestbookController` doesn't need the router, so it doesn't ask for it.
- If `validate()` fails, it throws an exception you never catch yourself — the framework catches it centrally, stores the errors in the session, and redirects back to the form (see [Error handling](#error-handling)). Your controller code only has to handle the *success* path.
- `secure()` must be called before you touch `$_POST` for anything real — it's the CSRF check, and it throws (redirecting back) if the hidden `csrf` field doesn't match.

### 5. Add the view

Create `resources/views/guestbook.advanced.php` (the `.advanced.php` extension picks the Blade-like engine — see [Views](#views)):

```blade
@extends('layout')

<div class="container mx-auto px-8 py-8">
<h1 class="text-3xl font-bold">Guestbook</h1>

@if(session()->has('guestbook_errors'))
<ol class="text-red-500">
@foreach(session('guestbook_errors') as $field => $errors)
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach
@endforeach
</ol>
@endif

<form method="post" action="/guestbook">
<input type="hidden" name="csrf" value="{{ $csrf }}" />
<input name="name" placeholder="Your name" />
<input name="body" placeholder="Your message" />
<button type="submit">Post</button>
</form>

<ul>
@foreach($messages as $message)
<li><strong>{{ $message->name }}</strong>: {{ $message->body }}</li>
@endforeach
</ul>
</div>
```

`@extends('layout')` wraps this in `resources/views/layout.advanced.php` (the shared page shell). `{{ }}` escapes output — always use it for anything a user typed, like `$message->name` and `$message->body` — and `{!! !!}` is only for HTML you trust (see [Views](#views)).

Notice the errors are read with `session()->has(...)` / `session(...)`, **not** `isset($_SESSION['guestbook_errors'])`. That matters: `session()->put(...)` namespaces every key with `config('session.native.prefix')` (`framework_` by default), so it's actually stored as `$_SESSION['framework_guestbook_errors']`. Reading it back through the `session()` helper strips that prefix for you automatically — reading the raw superglobal directly would silently never find it. See the [Sessions & CSRF](#sessions--csrf) section for more on this.

### 6. Try it

```
php command.php serve
```

Visit `http://127.0.0.1:8000/guestbook`. Submitting the form with an empty name should redirect back with a validation error printed at the top; filling it in should add a new `<li>` to the list. If something goes wrong, `APP_ENV=dev` gets you a full stack trace in the browser instead of a blank error (see [Error handling](#error-handling)).

That's the whole loop: **migration → model → route → controller → view**. Every feature in this app (and the reference sections below) is a variation on those same five steps.

## Bootstrapping & request lifecycle

Both entry points do the same three things:

```php
// public/index.php
$app = \Framework\App::getInstance();
$app->bind('paths.base', fn() => __DIR__ . '/..');
$app->prepare()->run()->send();
```

- `App::getInstance()` — `App` is a singleton (`framework/App.php`).
- `prepare()` — loads `.env` via `vlucas/phpdotenv`, then runs every provider listed in `config/providers.php`.
- `run()` — builds the `Router` from `app/routes.php` (once, cached on the container), dispatches the current request, and returns a `Framework\Http\Response`.
- `send()` — writes headers/status/body for that response.

`command.php` (the CLI entry point) does the same `bind('paths.base', ...) → prepare()`, but instead of `run()->send()` it hands control to a Symfony Console `Application` loaded with the commands from `app/commands.php`.

`server.php` is the router script for PHP's built-in server (`php -S`) — it serves static files directly out of `public/` and otherwise defers to `public/index.php`.

## The container

`Framework\Container` (`framework/Container.php`) is intentionally minimal:

```php
$app->bind('cache', fn($app) => new SomeCacheThing(...));
$app->resolve('cache'); // == app('cache')
$app->has('cache'); // bool
```

Bindings are **lazy singletons** — the factory closure only runs the first time you `resolve()` that alias, and the result is cached after that.

`Container::call($callable, $parameters = [])` is what powers automatic dependency injection for route handlers (see [Controllers](#controllers)). For each parameter of the target method/function it will:
1. use `$parameters[$name]` if you passed it explicitly,
2. use the parameter's default value if it has one,
3. otherwise try to `resolve()` an alias matching the parameter's **type name** as a string.

That third step means **only classes that have been explicitly bound under their own class name** can be auto-injected — there's no reflection-based auto-wiring of arbitrary classes. Concretely, `Router::class` is bound during `App::dispatch()`, which is why every controller can type-hint `Router $router` and get it for free:

```php
class ShowProductController
{
public function handle(Router $router) // auto-injected
{
$parameters = $router->current()->parameters();
// ...
}
}
```

## Configuration

Every file in `config/` returns a plain array. Access values with the `config()` helper, using dot notation:

```php
config('database.default'); // 'sqlite'
config('cache.file.seconds'); // 31536000
config('handlers.exceptions'); // App\Exceptions\Handler::class
```

`Framework\Support\Config` (`framework/Support/Config.php`) lazily `require`s `config/<first-segment>.php` the first time you ask for a key in it, then walks the rest of the dot-path.

Config files read secrets/environment-specific values via `env('KEY', 'default')`, which checks `$_SERVER`, then `$_ENV`, then `getenv()`, in that order (`framework/helpers.php`). `.env` (loaded by `App::configure()`) is where those values actually come from locally; in Docker they're set directly as container environment variables (see `docker-compose.yml`), which take priority since `Dotenv::createImmutable()` never overwrites variables that are already set.

The convention every driver-backed config file follows (`database.php`, `cache.php`, `session.php`, etc.) is:

```php
return [
'default' => env('DB_CONNECTION', 'sqlite'),
'sqlite' => ['type' => 'sqlite', 'path' => ...],
'mysql' => ['type' => 'mysql', 'host' => ...],
];
```

`DriverProvider` reads `'default'`, looks up that key, and passes the whole sub-array (which must include `'type'`) to the matching driver factory.

## Routing

Routes are declared in `app/routes.php`, which receives the `Router` and returns nothing:

```php
return function(Router $router) {
$router->add('GET', '/', [ShowHomePageController::class, 'handle'])
->name('show-home-page');

$router->add('GET', '/products/view/{product}', [ShowProductController::class, 'handle'])
->name('view-product');

$router->add('POST', '/products/order/{product}', [OrderProductController::class, 'handle'])
->name('order-product');

$router->errorHandler(404, fn() => 'whoops!');
};
```

- **Handler** is `[ControllerClass::class, 'methodName']` (or any callable) — dispatched through the container via `Container::call()`, so it gets constructor-free dependency injection (see above).
- **Parameters**: `{name}` is required, `{name?}` is optional. Read them in the handler via `$router->current()->parameters()` (an associative array; missing optional params come back as `false`).
- **Naming**: `->name('some-name')` lets you generate the path with `$router->route('some-name', ['product' => 5])`, which substitutes `{product}`/`{product?}` placeholders and strips any that are left over.
- **Error handlers**: `errorHandler(404, ...)`, `errorHandler(400, ...)` (method not allowed on a known path), and `errorHandler(500, ...)` are configurable per-app; each defaults to a plain string if you don't set one. The router sends the matching HTTP status code (404/400/500) along with whatever the handler returns — a plain `fn() => 'whoops!'` 404 handler still serves a real `404`. Return a full `Response` yourself only if you need to customize headers or the status further.
- Unhandled exceptions thrown inside a route handler are caught in `Router::dispatch()` and passed to the exception handler configured at `config('handlers.exceptions')` (see [Error handling](#error-handling)) before falling back to the 500 handler.

## Controllers

Controllers are plain classes with one public method per action — no base class required. A handler returns either:
- a `Framework\Http\Response` (built via `response()`),
- a `View` (from the `view()` helper — cast to string automatically), or
- a plain string/array (wrapped into a Response by `App::dispatch()`; arrays go through `response()->json()` implicitly only if you call `->json()` yourself — plain scalars just become the HTML body).

```php
class RegisterUserController
{
public function handle(Router $router)
{
secure(); // verify CSRF ($_POST['csrf'] against the session token)

$data = validate($_POST, [
'name' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'min:10'],
], 'register_errors');

$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = password_hash($data['password'], PASSWORD_BCRYPT);
$user->save();

return redirect($router->route('show-home-page'));
}
}
```

Common response helpers (`framework/helpers.php` / `Framework\Http\Response`):

```php
redirect($url); // 302-style redirect response
response()->json(['ok' => true]); // JSON response
response()->status(404)->content('Not found');
response()->header('X-Foo', 'bar');
```

## Views

`view($template, $data = [])` (from `framework/helpers.php`) resolves a template by trying every registered path × engine combination until a file exists, e.g. `resources/views/home.advanced.php`. Four engines are registered by `ViewProvider` (`framework/Provider/ViewProvider.php`):

| Extension | Engine | What it does |
|----------------|------------------|---------------|
| `.basic.php` | `BasicEngine` | `{key}` → naive `str_replace` against `$data`. No PHP execution. |
| `.advanced.php`| `AdvancedEngine` | A tiny Blade-like template language (compiled to a cached PHP file under `storage/framework/views/`). |
| `.php` | `PhpEngine` | Plain PHP templates — `$data` is `extract()`-ed, output is buffered. |
| `.svg` | `LiteralEngine` | Returns file contents verbatim (used for e.g. `resources/images/rocket.svg`). |

Views resolve against two paths: `resources/views/` and `resources/images/` (the latter is why `@includes('rocket')` finds an SVG).

### Advanced engine syntax

This is what most of the app's views use. It compiles a small directive set into real PHP, then caches the compiled file (recompiled only if the source is newer):

```blade
@extends('layout')
@includes('includes/large-feature')

@foreach($products as $i => $product)
<div class="@if($i % 2 === 0) bg-gray-50 @endif">
<h2>{{ $product->name }}</h2>
<p>{!! $product->description !!}</p>
</div>
@endforeach
```

- `{{ $expr }}` — escaped output (`htmlspecialchars($expr, ENT_QUOTES)`).
- `{!! $expr !!}` — raw, unescaped output.
- `@if(...) / @endif`, `@foreach(...) / @endforeach` — control structures.
- `@extends('layout')` — renders the current template's output as `$contents` inside `resources/views/layout.advanced.php`. The layout template does `{!! $contents !!}` wherever the child content should go.
- `@includes('some/view', [...])` and `@escape(...)` are **macros**, not built-in directives — any `@something(...)` not otherwise matched compiles to `$this->something(...)` on the engine, which forwards to `Manager::useMacro()`. `ViewProvider` registers `escape` and `includes` by default:

```php
$manager->addMacro('escape', fn($value) => htmlspecialchars($value, ENT_QUOTES));
$manager->addMacro('includes', fn(...$params) => print view(...$params));
```

You can register your own macros the same way from a provider (`$app->resolve('view')->addMacro('name', fn(...) => ...)`).

The plain `.php` engine (`PhpEngine`) supports `@extends`-equivalent behavior too, but as real PHP method calls (`$this->extends('layout')`) since there's no compilation step — it's just `extract()` + `include` + output buffering.

## Database: connections & query builder

`app('database')` resolves to a `Framework\Database\Connection\Connection` (either `MysqlConnection` or `SqliteConnection`, chosen by `config('database.default')`). Get a query builder from it with `->query()`, or start from a `Model` (see next section) — most app code does the latter.

```php
$connection = app('database');

$connection->query()->from('products')->select()->all(); // array of assoc arrays
$connection->query()->from('products')->where('id', 5)->first(); // one row, or null
$connection->query()->from('products')->insert(['name', 'description'], [
'name' => 'Space Tour',
'description' => '...',
]);
$connection->query()->from('orders')->where('id', $id)->update(['quantity'], ['quantity' => 3]);
$connection->query()->from('orders')->where('id', $id)->delete();
```

Query builder notes (`framework/Database/QueryBuilder/QueryBuilder.php`):
- `where($column, $value)` (implicit `=`) or `where($column, $comparator, $value)` for `<`, `>`, `<=`, etc. Multiple `where()` calls are AND-ed together.
- `take($limit, $offset = 0)` for pagination; `first()` internally uses `take(1)`.
- All values are bound as named PDO placeholders (`:column`) — never interpolated into SQL, so this is safe against SQL injection as long as you always go through `where()`/`insert()`/`update()`.
- `getLastInsertId()` wraps `PDO::lastInsertId()`.

## Models & relationships

`Framework\Database\Model` is a thin ActiveRecord-style base class. Table name comes from either `protected string $table = '...'` or a `#[TableName('...')]` attribute:

```php
#[TableName('users')]
class User extends Model
{
public function profile(): mixed
{
return $this->hasOne(Profile::class, 'user_id');
}

public function orders(): mixed
{
return $this->hasMany(Order::class, 'user_id');
}
}

class Order extends Model
{
protected string $table = 'orders';

public function user(): mixed
{
return $this->belongsTo(User::class, 'user_id');
}
}
```

### Querying

`Model::query()` (or any static method — `User::where(...)`, `Product::all()`, etc. via `__callStatic`) returns a `ModelCollector`, which wraps the query builder and turns rows back into model instances:

```php
User::find(5); // by id, or null
User::where('email', $email)->first(); // Model|null
Product::all(); // Model[]
Product::where('id', '>', 10)->take(5)->all();
```

### Reading/writing attributes

Attributes are accessed dynamically via `__get`/`__set` — there's no need to declare properties:

```php
$user = new User();
$user->name = 'Chris'; // tracked as "dirty"
$user->email = 'chris@example.com';
$user->password = password_hash('secret1234', PASSWORD_BCRYPT);
$user->save(); // INSERT since there's no id yet; sets $user->id after
```

`save()` does an `UPDATE ... WHERE id = ?` if `$this->attributes['id']` is already set, otherwise an `INSERT` (only dirty attributes are written). `delete()` removes the row by id.

**Accessors/mutators** — define `get{Field}Attribute($value)` / `set{Field}Attribute($value)` to transform data on the way in/out:

```php
class Product extends Model
{
protected string $table = 'products';

public function getNameAttribute($value): string
{
return ucwords($value);
}

public function setDescriptionAttribute(string $value)
{
// truncate long descriptions before they're stored
return mb_strwidth($value, 'UTF-8') <= 50
? $value
: rtrim(mb_strimwidth($value, 0, 50, '', 'UTF-8')) . '...';
}
}
```

**Casts** — `protected array $casts = ['is_confirmed' => fn($v) => (bool) $v];` applies a callable to the raw value every time that attribute is read.

### Relationships

`hasOne`, `hasMany`, and `belongsTo` all return a `Relationship`, which resolves eagerly when accessed as a property (`Model::__get` detects the method and calls `first()`/`all()` on it for you):

```php
$user->profile; // Profile|null — resolves hasOne immediately
$user->orders; // Order[] — resolves hasMany immediately
$order->user; // User|null — resolves belongsTo immediately
```

You can also call the relationship method directly (`$user->orders()`) if you want the underlying `ModelCollector`/`Relationship` object instead of the eagerly-resolved value — useful if you want to chain more query constraints before fetching.

## Migrations

Files in `database/migrations/` are numbered (`001_CreateOrdersTable.php`, `002_...`) and named after a class with a `migrate(Connection $connection)` method. `MigrateCommand` (`framework/Database/Command/MigrateCommand.php`) globs the directory, checks which class names are already logged in the `migrations` table, and runs everything else **in filename order** — so it's safe to run repeatedly as you add new migration files; it only ever applies the ones it hasn't seen before.

```php
class CreateOrdersTable
{
public function migrate(Connection $connection)
{
$table = $connection->createTable('orders');
$table->id('id');
$table->int('quantity')->default(1);
$table->float('price')->nullable();
$table->bool('is_confirmed')->default(false);
$table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP');
$table->text('notes');
$table->execute();
}
}
```

Field types (`framework/Database/Migration/Field/*`): `id()`, `int()`, `float()`, `bool()`, `string()`, `text()`, `dateTime()`. Each supports `->nullable()` and `->default(...)` (not `text()`, which can't be nullable). To alter an existing table:

```php
class ChangeQuantity
{
public function migrate(Connection $connection)
{
$table = $connection->alterTable('orders');
$table->int('quantity')->nullable()->alter(); // MODIFY / rebuild existing column
$table->execute();
}
}

class DropPrice
{
public function migrate(Connection $connection)
{
$connection->alterTable('orders')->dropColumn('price')->execute();
}
}
```

Run migrations with:

```
php command.php migrate # run any migration files not yet logged as applied
php command.php migrate --fresh # drop all tables first, then run every migration file
```

Use `--fresh` when you want a clean slate (it drops everything, including data) — day to day, plain `migrate` after pulling new migration files is the normal workflow.

**MySQL vs SQLite**: both connections support the same migration DSL, but they compile very differently under the hood (`MysqlMigration` vs `SqliteMigration`). MySQL can `ALTER`/`DROP COLUMN` directly. SQLite can't do either natively, so `SqliteMigration` rebuilds the table instead — it creates a new table with the desired final schema (using `PRAGMA table_info` to reconstruct unaffected columns), copies the surviving rows across, drops the old table, and renames the new one into place. This all happens transparently; you write the same migration file either way.

## Validation

`validate($data, $rules, $sessionName = 'errors')` (`framework/helpers.php`) runs `Framework\Validation\Manager::validate()`. On success it returns only the keys mentioned in `$rules`; on failure it throws `ValidationException` (caught centrally — see [Error handling](#error-handling)) and clears any previous errors from the session if it now passes.

```php
$data = validate($_POST, [
'email' => ['required', 'email'],
'password' => ['required', 'min:10'],
], 'login_errors');
```

Built-in rules (`framework/Validation/Rule/*`), registered by `ValidationProvider`: `required`, `email` (just checks for `@`), `min:N` (string length). Rules with a `:` take comma-separated params (`min:10` → `params = ['10']`).

Add a custom rule by implementing `Framework\Validation\Rule\Rule` (`validate()` + `getMessage()`) and registering it from a provider:

```php
$app->resolve('validator')->addRule('alpha', new AlphaRule());
```

On a `ValidationException`, `ExceptionHandler::showValidationException()` puts the errors array into the session under `$sessionName` (via `session()->put(...)`, so it's namespaced with the session prefix — see [Sessions & CSRF](#sessions--csrf)) and redirects back to `HTTP_REFERER`. That only works if a `Referer` header is actually present on the request — a real browser form submission sends one automatically, but a bare `curl -d ...` or API call won't, so don't rely on it outside a normal HTML form flow.

Read the errors back out with the `session()` helper, not the raw `$_SESSION` superglobal (see the callout in [Sessions & CSRF](#sessions--csrf) for why that distinction matters):

```blade
@if(session()->has('errors'))
@foreach(session('errors') as $field => $errors)
@foreach($errors as $error)
<li>{{ $error }}</li>
@endforeach
@endforeach
@endif
```

## Sessions & CSRF

`session()` (no args) returns the session driver (`app('session')`); `session($key, $default)` reads a value directly. Only one driver exists — `NativeDriver`, a thin wrapper over PHP's native `$_SESSION`, namespaced with a configurable prefix (`config('session.native.prefix')`, default `framework_`) so framework session keys don't collide with raw `$_SESSION` usage elsewhere in the app.

```php
session()->put('user_id', $user->id);
session('user_id'); // read
session()->has('user_id');
session()->forget('user_id');
session()->flush(); // clears everything under this driver's prefix
```

> **Gotcha:** `session()->put('user_id', ...)` doesn't store to `$_SESSION['user_id']` — it stores to `$_SESSION['framework_user_id']` (prefix from `config('session.native.prefix')`). Anything written through the `session()` helper has to be *read back* through the `session()` helper too (`session('user_id')` / `session()->has('user_id')`); reading the raw `$_SESSION['user_id']` superglobal directly will silently come back empty. The reverse is also true: code that writes straight to `$_SESSION[...]` (bypassing `session()->put()`) won't be visible to `session()->get(...)`. Pick one approach per key and stick to it — mixing them for the same key is the easiest way to end up debugging a value that "isn't there" when it actually is, just under a different key. (This app used to mix the two — the nav partials and the product page checked `$_SESSION[...]` directly and would never see values written via `session()->put()`. That's fixed now; every view and controller in this app goes through the `session()` helper consistently.)

CSRF helpers:

```php
csrf(); // generates a token, stores it in session('token'), returns it — put in a hidden form field
secure(); // throws if $_POST['csrf'] doesn't match session('token'); call this at the top of any state-changing handler
```

## Caching

`app('cache')` gives you the driver named by `config('cache.default')` (default `memory`, an in-process array that doesn't survive the request — mostly useful in tests). `file` persists JSON blobs to `storage/framework/cache/`; `memcache` talks to a real Memcached server.

```php
$cache = app('cache');

if (!$cache->has($key)) {
$cache->put($key, $expensiveValue, 3600); // seconds; omit to use config('cache.<driver>.seconds')
}

$cache->get($key, $default);
$cache->forget($key);
$cache->flush();
```

## Filesystem

`app('filesystem')` wraps [League Flysystem](https://flysystem.thephpleague.com/). Only `local` is actually implemented (`LocalDriver`, rooted at `config('filesystem.local.path')`, i.e. `storage/app`); `s3`/`ftp` config stubs exist but have no matching driver class yet — add one the same way `LocalDriver` was added if you need it.

```php
$fs = app('filesystem');

$fs->put('avatars/1.png', $binaryContents);
$fs->get('avatars/1.png');
$fs->exists('avatars/1.png');
$fs->delete('avatars/1.png');
$fs->list('avatars', recursive: true);
```

## Email

`app('email')` gives a fluent message builder for the configured driver (`config('email.default')`, currently only `postmark`, via SwiftMailer + `wildbit/swiftmailer-postmark`):

```php
app('email')
->to('someone@example.com')
->subject('Welcome')
->text('Thanks for signing up.')
// ->html('<p>...</p>') // text, html, or both (multipart)
->send();
```

Requires `EMAIL_TOKEN`, `EMAIL_FROM_NAME`, `EMAIL_FROM_EMAIL` in `.env`.

## Queue & background jobs

`app('queue')->push($closure, ...$params)` serializes a closure (via `opis/closure`) and its arguments into the `jobs` table (`config('queue.database.table')`) and returns the new job's id:

```php
app('queue')->push(function($name) {
app('email')->to('someone@example.com')->text("Hello {$name}")->send();
}, 'Chris');
```

Run the worker (a blocking loop that polls for jobs, `sleep(1)` between successful runs) with:

```
php command.php queue:work
```

On failure, the job's `attempts` is incremented and it stays eligible for retry until it hits `config('queue.database.attempts')` (default 3), after which `shift()` stops returning it. There's only a `database` driver right now (`DatabaseDriver`), which is why `10_CreateJobsTable.php` exists as a migration — the queue is backed by your app's own database.

## Logging

`app('logging')` is a [Monolog](https://github.com/Seldaek/monolog) logger (`StreamDriver`) writing to `config('logging.stream.path')` (default `storage/app.log`) at `config('logging.stream.minimum')` level and above.

```php
app('logging')->info('Something happened');
app('logging')->warning('...');
app('logging')->error('...');
```

## Error handling

Uncaught exceptions inside a route handler are caught by `Router::dispatch()` and passed to whatever class `config('handlers.exceptions')` points to (`App\Exceptions\Handler` by default, which just extends and delegates to `Framework\Support\ExceptionHandler`):

- **`ValidationException`** → errors are flashed to the session and the response redirects back to `HTTP_REFERER` (see [Validation](#validation)).
- **Anything else, when `APP_ENV=dev`** → re-thrown into a registered [Whoops](https://github.com/filp/whoops) pretty-page handler for a full interactive stack trace in the browser.
- **Anything else in any other environment** → falls through to the router's plain-text 500 handler (`Router::dispatchError()`).

To add your own reporting (e.g. send to an error tracker) without losing the built-in behavior, override `showThrowable()` in `App\Exceptions\Handler` and call `parent::showThrowable($throwable)`:

```php
class Handler extends ExceptionHandler
{
public function showThrowable(Throwable $throwable)
{
// report($throwable) to your monitoring service here...
return parent::showThrowable($throwable);
}
}
```

## CLI commands

`command.php` boots the app the same way `public/index.php` does, then hands off to a Symfony Console `Application` loaded with every class listed in `app/commands.php`:

```
php command.php migrate [--fresh]
php command.php serve [--host=...] [--port=...] # PHP's built-in server, via server.php
php command.php queue:work
```

Add your own by writing a Symfony `Command` class anywhere (`framework/Support/Command/ServeCommand.php` is a good template) and appending it to the array in `app/commands.php`.

## Testing

Tests extend `Framework\Testing\TestCase` (a thin `PHPUnit\Framework\TestCase` wrapper adding `assertExceptionThrown(Closure $risky, string $exceptionType)`, which returns `[$exception, $result]`).

**Simulating a full request** without a real HTTP call — set the superglobals the router reads, then run the app directly:

```php
class RoutingTest extends TestCase
{
public function testHomePageIsShown()
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/';

$this->assertStringContainsString('Take a trip on a rocket ship', app()->run()->content());
}

public function testRegistrationErrorsAreShown()
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/register';
$_SERVER['HTTP_REFERER'] = '/register';
$_POST['email'] = 'foo';
$_POST['csrf'] = csrf();

$response = new TestResponse(app()->run());

$this->assertTrue($response->isRedirecting());
$this->assertEquals('/register', $response->redirectingTo());
}
}
```

`Framework\Testing\TestResponse` wraps a `Response` and adds `isRedirecting()`, `redirectingTo()`, and `follow()` (re-dispatches the app against the redirect target, repeatedly, useful for chaining through multiple redirects in a test).

**Real browser tests** (`tests/BrowserTest.php`) use `symfony/panther` + Firefox against a real running server. `Framework\Testing\ServerExtension` (registered in `phpunit.xml` under `<extensions>`) automatically starts `php command.php serve` before the suite if nothing is already listening on `APP_HOST:APP_PORT`, and stops it after.

## Helper function reference

All defined in `framework/helpers.php`, globally available, no imports needed:

| Helper | Purpose |
|---|---|
| `app($alias = null)` | No args: the `App` singleton. With an alias: `App::resolve($alias)`, e.g. `app('database')`. |
| `view($template, $data = [])` | Resolve and render a template — returns a `View` (stringable). |
| `validate($data, $rules, $sessionName = 'errors')` | Run validation; throws `ValidationException` on failure. |
| `response()` | `app('response')` — a fresh `Framework\Http\Response`. |
| `redirect($url)` | `response()->redirect($url)`. |
| `csrf()` | Generate + store + return a CSRF token. |
| `secure()` | Assert `$_POST['csrf']` matches the session token; throws on mismatch. |
| `dd(...$params)` | `var_dump` then `die`. |
| `basePath()` | `app('paths.base')`. |
| `env($key, $default = null)` | Read an environment variable ($_SERVER → $_ENV → getenv → default). |
| `config($key = null, $default = null)` | No args: the `Config` service. With a dotted key: a config value. |
| `session($key = null, $default = null)` | No args: the session driver. With a key: a stored value. |

+ 78
- 0
framework/App.php Vedi File

@@ -0,0 +1,78 @@
<?php

namespace Framework;

use Dotenv\Dotenv;
use Framework\Routing\Router;
use Framework\Http\Response;

class App extends Container
{
private static $instance;

public static function getInstance()
{
if (!static::$instance) {
static::$instance = new static();
}

return static::$instance;
}

private function __construct() {}
private function __clone() {}

public function prepare(): static
{
$basePath = $this->resolve('paths.base');

$this->configure($basePath);
$this->bindProviders($basePath);

return $this;
}

public function run(): Response
{
return $this->dispatch($this->resolve('paths.base'));
}

private function configure(string $basePath)
{
$dotenv = Dotenv::createImmutable($basePath);
$dotenv->load();
}

private function bindProviders(string $basePath)
{
$providers = require "{$basePath}/config/providers.php";

foreach ($providers as $provider) {
$instance = new $provider;

if (method_exists($instance, 'bind')) {
$instance->bind($this);
}
}
}

private function dispatch(string $basePath): Response
{
if (!$this->has(Router::class)) {
$router = new Router();

$routes = require "{$basePath}/app/routes.php";
$routes($router);

$this->bind(Router::class, fn() => $router);
}

$response = $this->resolve(Router::class)->dispatch();

if (!$response instanceof Response) {
$response = $this->resolve('response')->content($response);
}

return $response;
}
}

+ 31
- 0
framework/Cache/Driver/Driver.php Vedi File

@@ -0,0 +1,31 @@
<?php

namespace Framework\Cache\Driver;

interface Driver
{
/**
* Tell if a value is cached (still)
*/
public function has(string $key): bool;

/**
* Get a cached value
*/
public function get(string $key, mixed $default = null): mixed;

/**
* Put a value into the cache, for an optional number of seconds
*/
public function put(string $key, mixed $value, int $seconds = null): static;

/**
* Remove a single cached value
*/
public function forget(string $key): static;

/**
* Remove all cached values
*/
public function flush(): static;
}

+ 111
- 0
framework/Cache/Driver/FileDriver.php Vedi File

@@ -0,0 +1,111 @@
<?php

namespace Framework\Cache\Driver;

use Framework\App;

class FileDriver implements Driver
{
private array $config = [];
private array $cached = [];

public function __construct(array $config)
{
$this->config = $config;
}

public function has(string $key): bool
{
$data = $this->cached[$key] = $this->read($key);

return isset($data['expires']) and $data['expires'] > time();
}

private function path(string $key): string
{
$base = $this->base();
$separator = DIRECTORY_SEPARATOR;
$key = sha1($key);

return "{$base}{$separator}{$key}.json";
}

private function base(): string
{
$base = App::getInstance()->resolve('paths.base');
$separator = DIRECTORY_SEPARATOR;

return "{$base}{$separator}storage{$separator}framework{$separator}cache";
}

private function read(string $key)
{
$path = $this->path($key);

if (!is_file($path)) {
return [];
}

return json_decode(file_get_contents($path), true);
}

public function get(string $key, mixed $default = null): mixed
{
if ($this->has($key)) {
return $this->cached[$key]['value'];
}

return $default;
}

public function put(string $key, mixed $value, int $seconds = null): static
{
if (!is_int($seconds)) {
$seconds = (int) $this->config['seconds'];
}

$data = $this->cached[$key] = [
'value' => $value,
'expires' => time() + $seconds,
];

return $this->write($key, $data);
}

private function write(string $key, mixed $value): static
{
file_put_contents($this->path($key), json_encode($value));
return $this;
}

public function forget(string $key): static
{
unset($this->cached[$key]);
$path = $this->path($key);

if (is_file($path)) {
unlink($path);
}

return $this;
}

public function flush(): static
{
$this->cached = [];

$base = $this->base();
$separator = DIRECTORY_SEPARATOR;

$files = glob("{$base}{$separator}*.json");

foreach ($files as $file){
if (is_file($file)) {
unlink($file);
}
}

return $this;
}
}

+ 55
- 0
framework/Cache/Driver/MemcacheDriver.php Vedi File

@@ -0,0 +1,55 @@
<?php

namespace Framework\Cache\Driver;

use Memcached;

class MemcacheDriver implements Driver
{
private array $config = [];
private Memcached $memcache;

public function __construct(array $config)
{
$this->config = $config;

$this->memcache = new Memcached();
$this->memcache->addServer($config['host'], $config['port']);
}

public function has(string $key): bool
{
return $this->memcache->get($key) !== false;
}

public function get(string $key, mixed $default = null): mixed
{
if ($value = $this->memcache->get($key)) {
return $value;
}

return $default;
}

public function put(string $key, mixed $value, int $seconds = null): static
{
if (!is_int($seconds)) {
$seconds = (int) $this->config['seconds'];
}

$this->memcache->set($key, $value, time() + $seconds);
return $this;
}

public function forget(string $key): static
{
$this->memcache->delete($key);
return $this;
}

public function flush(): static
{
$this->memcache->flush();
return $this;
}
}

+ 54
- 0
framework/Cache/Driver/MemoryDriver.php Vedi File

@@ -0,0 +1,54 @@
<?php

namespace Framework\Cache\Driver;

class MemoryDriver implements Driver
{
private array $config = [];
private array $cached = [];

public function __construct(array $config)
{
$this->config = $config;
}

public function has(string $key): bool
{
return isset($this->cached[$key]) && $this->cached[$key]['expires'] > time();
}

public function get(string $key, mixed $default = null): mixed
{
if ($this->has($key)) {
return $this->cached[$key]['value'];
}

return $default;
}

public function put(string $key, mixed $value, int $seconds = null): static
{
if (!is_int($seconds)) {
$seconds = (int) $this->config['seconds'];
}

$this->cached[$key] = [
'value' => $value,
'expires' => time() + $seconds,
];

return $this;
}

public function forget(string $key): static
{
unset($this->cached[$key]);
return $this;
}

public function flush(): static
{
$this->cached = [];
return $this;
}
}

+ 9
- 0
framework/Cache/Exception/DriverException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Cache\Exception;

use RuntimeException;

class DriverException extends RuntimeException
{
}

+ 34
- 0
framework/Cache/Factory.php Vedi File

@@ -0,0 +1,34 @@
<?php

namespace Framework\Cache;

use Closure;
use Framework\Cache\Driver\Driver;
use Framework\Cache\Exception\DriverException;
use Framework\Support\DriverFactory;

class Factory implements DriverFactory
{
protected array $drivers;

public function addDriver(string $alias, Closure $driver): static
{
$this->drivers[$alias] = $driver;
return $this;
}

public function connect(array $config): Driver
{
if (!isset($config['type'])) {
throw new DriverException('type is not defined');
}

$type = $config['type'];

if (isset($this->drivers[$type])) {
return $this->drivers[$type]($config);
}

throw new DriverException('unrecognised type');
}
}

+ 80
- 0
framework/Container.php Vedi File

@@ -0,0 +1,80 @@
<?php

namespace Framework;

use InvalidArgumentException;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionNamedType;

class Container
{
private array $bindings = [];
private array $resolved = [];

public function bind(string $alias, callable $factory): static
{
$this->bindings[$alias] = $factory;
$this->resolved[$alias] = null;

return $this;
}

public function resolve(string $alias): mixed
{
if (!isset($this->bindings[$alias])) {
throw new InvalidArgumentException("{$alias} is not bound");
}

if (!isset($this->resolved[$alias])) {
$this->resolved[$alias] = call_user_func($this->bindings[$alias], $this);
}

return $this->resolved[$alias];
}

public function has(string $alias): bool
{
return isset($this->bindings[$alias]);
}

public function call(array|callable $callable, array $parameters = []): mixed
{
$reflector = $this->getReflector($callable);

$dependencies = [];

foreach ($reflector->getParameters() as $parameter) {
$name = $parameter->getName();
$type = $parameter->getType();

if (isset($parameters[$name])) {
$dependencies[$name] = $parameters[$name];
continue;
}

if ($parameter->isDefaultValueAvailable()) {
$dependencies[$name] = $parameter->getDefaultValue();
continue;
}

if ($type instanceof ReflectionNamedType) {
$dependencies[$name] = $this->resolve($type);
continue;
}

throw new InvalidArgumentException("{$name} cannot be resolved");
}

return call_user_func($callable, ...array_values($dependencies));
}

private function getReflector(array|callable $callable): ReflectionMethod|ReflectionFunction
{
if (is_array($callable)) {
return new ReflectionMethod($callable[0], $callable[1]);
}

return new ReflectionFunction($callable);
}
}

+ 111
- 0
framework/Database/Command/MigrateCommand.php Vedi File

@@ -0,0 +1,111 @@
<?php

namespace Framework\Database\Command;

use Framework\Database\Factory;
use Framework\Database\Connection\Connection;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class MigrateCommand extends Command
{
protected static $defaultName = 'migrate';

protected function configure()
{
$this
->setDescription('Migrates the database')
->addOption('fresh', null, InputOption::VALUE_NONE, 'Delete all tables before running the migrations')
->setHelp('This command looks for all migration files and runs them');
}

protected function execute(InputInterface $input, OutputInterface $output)
{
$current = getcwd();
$pattern = 'database/migrations/*.php';

$paths = glob("{$current}/{$pattern}");

if (count($paths) < 1) {
$this->writeln('No migrations found');
return Command::SUCCESS;
}

// $connection = $this->connection();
$connection = app('database');

if ($input->getOption('fresh')) {
$output->writeln('Dropping existing database tables');

$connection->dropTables();

// $connection = $this->connection();
$connection = app('database');
}

$applied = [];

if (!$connection->hasTable('migrations')) {
$output->writeln('Creating migrations table');
$this->createMigrationsTable($connection);
} else {
$applied = array_column(
$connection->query()->from('migrations')->select(['name'])->all(),
'name'
);
}

foreach ($paths as $path) {
[$prefix, $file] = explode('_', $path);
[$class, $extension] = explode('.', $file);

if (in_array($class, $applied)) {
continue;
}

require $path;

$output->writeln("Migrating: {$class}");

$obj = new $class();
$obj->migrate($connection);

$connection
->query()
->from('migrations')
->insert(['name'], ['name' => $class]);
}

return Command::SUCCESS;
}

// private function connection(): Connection
// {
// $factory = new Factory();

// $factory->addConnector('mysql', function($config) {
// return new MysqlConnection($config);
// });

// $factory->addConnector('sqlite', function($config) {
// return new SqliteConnection($config);
// });

// $config = require getcwd() . '/config/database.php';

// return $factory->connect($config[$config['default']]);
// }

private function createMigrationsTable(Connection $connection)
{
$table = $connection->createTable('migrations');
$table->id('id');
$table->string('name');
$table->execute();
}
}

+ 45
- 0
framework/Database/Connection/Connection.php Vedi File

@@ -0,0 +1,45 @@
<?php

namespace Framework\Database\Connection;

use Framework\Database\Migration\Migration;
use Framework\Database\QueryBuilder\QueryBuilder;
use Pdo;

abstract class Connection
{
/**
* Get the underlying Pdo instance for this connection
*/
abstract public function pdo(): Pdo;

/**
* Start a new query on this connection
*/
abstract public function query(): QueryBuilder;

/**
* Start a new migration to add a table on this connection
*/
abstract public function createTable(string $table): Migration;

/**
* Start a new migration to add a table on this connection
*/
abstract public function alterTable(string $table): Migration;

/**
* Return a list of table names on this connection
*/
abstract public function getTables(): array;

/**
* Find out if a table exists on this connection
*/
abstract public function hasTable(string $name): bool;

/**
* Drop all tables in the current database
*/
abstract public function dropTables(): int;
}

+ 94
- 0
framework/Database/Connection/MysqlConnection.php Vedi File

@@ -0,0 +1,94 @@
<?php

namespace Framework\Database\Connection;

use Framework\Database\Migration\MysqlMigration;
use Framework\Database\QueryBuilder\MysqlQueryBuilder;
use InvalidArgumentException;
use Pdo;

class MysqlConnection extends Connection
{
private Pdo $pdo;
private string $database;

public function __construct(array $config)
{
[
'host' => $host,
'port' => $port,
'database' => $database,
'username' => $username,
'password' => $password,
] = $config;

if (empty($host) || empty($database) || empty($username)) {
throw new InvalidArgumentException('Connection incorrectly configured');
}

$this->database = $database;

$this->pdo = new Pdo("mysql:host={$host};port={$port};dbname={$database}", $username, $password);
}

public function pdo(): Pdo
{
return $this->pdo;
}
public function query(): MysqlQueryBuilder
{
return new MysqlQueryBuilder($this);
}

public function createTable(string $table): MysqlMigration
{
return new MysqlMigration($this, $table, 'create');
}

public function alterTable(string $table): MysqlMigration
{
return new MysqlMigration($this, $table, 'alter');
}

public function getTables(): array
{
$statement = $this->pdo->prepare('SHOW TABLES');
$statement->execute();

$results = $statement->fetchAll(PDO::FETCH_NUM);
$results = array_map(fn($result) => $result[0], $results);

return $results;
}

public function hasTable(string $name): bool
{
$tables = $this->getTables();
return in_array($name, $tables);
}

public function dropTables(): int
{
$statement = $this->pdo->prepare("
SELECT CONCAT('DROP TABLE IF EXISTS `', table_name, '`')
FROM information_schema.tables
WHERE table_schema = '{$this->database}';
");

$statement->execute();

$dropTableClauses = $statement->fetchAll(PDO::FETCH_NUM);
$dropTableClauses = array_map(fn($result) => $result[0], $dropTableClauses);

$clauses = [
'SET FOREIGN_KEY_CHECKS = 0',
...$dropTableClauses,
'SET FOREIGN_KEY_CHECKS = 1',
];

$statement = $this->pdo->prepare(join(';', $clauses) . ';');

return $statement->execute();
}
}

+ 69
- 0
framework/Database/Connection/SqliteConnection.php Vedi File

@@ -0,0 +1,69 @@
<?php

namespace Framework\Database\Connection;

use Framework\Database\Migration\SqliteMigration;
use Framework\Database\QueryBuilder\SqliteQueryBuilder;
use InvalidArgumentException;
use Pdo;

class SqliteConnection extends Connection
{
private Pdo $pdo;
private array $config;

public function __construct(array $config)
{
['path' => $path] = $config;

if (empty($path)) {
throw new InvalidArgumentException('Connection incorrectly configured');
}

$this->pdo = new Pdo("sqlite:{$path}");
$this->config = $config;
}

public function pdo(): Pdo
{
return $this->pdo;
}
public function query(): SqliteQueryBuilder
{
return new SqliteQueryBuilder($this);
}

public function createTable(string $table): SqliteMigration
{
return new SqliteMigration($this, $table, 'create');
}

public function alterTable(string $table): SqliteMigration
{
return new SqliteMigration($this, $table, 'alter');
}

public function getTables(): array
{
$statement = $this->pdo->prepare("SELECT name FROM sqlite_master WHERE type = 'table'");
$statement->execute();

$results = $statement->fetchAll(PDO::FETCH_NUM);
$results = array_map(fn($result) => $result[0], $results);

return $results;
}

public function hasTable(string $name): bool
{
$tables = $this->getTables();
return in_array($name, $tables);
}

public function dropTables(): int
{
file_put_contents($this->config['path'], '');
return 1;
}
}

+ 9
- 0
framework/Database/Exception/ConnectionException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Database\Exception;

use PDOException;

class ConnectionException extends PDOException
{
}

+ 9
- 0
framework/Database/Exception/MigrationException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Database\Exception;

use PDOException;

class MigrationException extends PDOException
{
}

+ 9
- 0
framework/Database/Exception/QueryException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Database\Exception;

use PDOException;

class ConnectionException extends PDOException
{
}

+ 34
- 0
framework/Database/Factory.php Vedi File

@@ -0,0 +1,34 @@
<?php

namespace Framework\Database;

use Closure;
use Framework\Database\Connection\Connection;
use Framework\Database\Exception\ConnectionException;
use Framework\Support\DriverFactory;

class Factory implements DriverFactory
{
protected array $drivers;

public function addDriver(string $alias, Closure $driver): static
{
$this->drivers[$alias] = $driver;
return $this;
}

public function connect(array $config): Connection
{
if (!isset($config['type'])) {
throw new ConnectionException('type is not defined');
}

$type = $config['type'];

if (isset($this->drivers[$type])) {
return $this->drivers[$type]($config);
}

throw new ConnectionException('unrecognised type');
}
}

+ 14
- 0
framework/Database/Migration/Field/BoolField.php Vedi File

@@ -0,0 +1,14 @@
<?php

namespace Framework\Database\Migration\Field;

class BoolField extends Field
{
public ?bool $default = null;

public function default(bool $value): static
{
$this->default = $value;
return $this;
}
}

+ 14
- 0
framework/Database/Migration/Field/DateTimeField.php Vedi File

@@ -0,0 +1,14 @@
<?php

namespace Framework\Database\Migration\Field;

class DateTimeField extends Field
{
public ?string $default = null;

public function default(string $value): static
{
$this->default = $value;
return $this;
}
}

+ 27
- 0
framework/Database/Migration/Field/Field.php Vedi File

@@ -0,0 +1,27 @@
<?php

namespace Framework\Database\Migration\Field;

abstract class Field
{
public string $name;
public bool $nullable = false;
public bool $alter = false;

public function __construct(string $name)
{
$this->name = $name;
}

public function nullable(): static
{
$this->nullable = true;
return $this;
}

public function alter(): static
{
$this->alter = true;
return $this;
}
}

+ 14
- 0
framework/Database/Migration/Field/FloatField.php Vedi File

@@ -0,0 +1,14 @@
<?php

namespace Framework\Database\Migration\Field;

class FloatField extends Field
{
public ?float $default = null;

public function default(float $value): static
{
$this->default = $value;
return $this;
}
}

+ 13
- 0
framework/Database/Migration/Field/IdField.php Vedi File

@@ -0,0 +1,13 @@
<?php

namespace Framework\Database\Migration\Field;

use Framework\Database\Exception\MigrationException;

class IdField extends Field
{
public function default()
{
throw new MigrationException('ID fields cannot have a default value');
}
}

+ 14
- 0
framework/Database/Migration/Field/IntField.php Vedi File

@@ -0,0 +1,14 @@
<?php

namespace Framework\Database\Migration\Field;

class IntField extends Field
{
public ?int $default = null;

public function default(int $value): static
{
$this->default = $value;
return $this;
}
}

+ 14
- 0
framework/Database/Migration/Field/StringField.php Vedi File

@@ -0,0 +1,14 @@
<?php

namespace Framework\Database\Migration\Field;

class StringField extends Field
{
public ?string $default = null;

public function default(string $value): static
{
$this->default = $value;
return $this;
}
}

+ 21
- 0
framework/Database/Migration/Field/TextField.php Vedi File

@@ -0,0 +1,21 @@
<?php

namespace Framework\Database\Migration\Field;

use Framework\Database\Exception\MigrationException;

class TextField extends Field
{
public ?string $default = null;

public function nullable(): static
{
throw new MigrationException('Text fields cannot be nullable');
}

public function default(string $value): static
{
$this->default = $value;
return $this;
}
}

+ 62
- 0
framework/Database/Migration/Migration.php Vedi File

@@ -0,0 +1,62 @@
<?php

namespace Framework\Database\Migration;

use Framework\Database\Connection\Connection;
use Framework\Database\Migration\Field\BoolField;
use Framework\Database\Migration\Field\DateTimeField;
use Framework\Database\Migration\Field\FloatField;
use Framework\Database\Migration\Field\IdField;
use Framework\Database\Migration\Field\IntField;
use Framework\Database\Migration\Field\StringField;
use Framework\Database\Migration\Field\TextField;

abstract class Migration
{
protected array $fields = [];

public function bool(string $name): BoolField
{
$field = $this->fields[] = new BoolField($name);
return $field;
}

public function dateTime(string $name): DateTimeField
{
$field = $this->fields[] = new DateTimeField($name);
return $field;
}

public function float(string $name): FloatField
{
$field = $this->fields[] = new FloatField($name);
return $field;
}

public function id(string $name): IdField
{
$field = $this->fields[] = new IdField($name);
return $field;
}

public function int(string $name): IntField
{
$field = $this->fields[] = new IntField($name);
return $field;
}

public function string(string $name): StringField
{
$field = $this->fields[] = new StringField($name);
return $field;
}

public function text(string $name): TextField
{
$field = $this->fields[] = new TextField($name);
return $field;
}

abstract public function execute();
abstract public function dropColumn(string $name): static;
}

+ 164
- 0
framework/Database/Migration/MysqlMigration.php Vedi File

@@ -0,0 +1,164 @@
<?php

namespace Framework\Database\Migration;

use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Exception\MigrationException;
use Framework\Database\Migration\Field\Field;
use Framework\Database\Migration\Field\BoolField;
use Framework\Database\Migration\Field\DateTimeField;
use Framework\Database\Migration\Field\FloatField;
use Framework\Database\Migration\Field\IdField;
use Framework\Database\Migration\Field\IntField;
use Framework\Database\Migration\Field\StringField;
use Framework\Database\Migration\Field\TextField;

class MysqlMigration extends Migration
{
protected MysqlConnection $connection;
protected string $table;
protected string $type;
protected array $drops = [];

public function __construct(MysqlConnection $connection, string $table, string $type)
{
$this->connection = $connection;
$this->table = $table;
$this->type = $type;
}

public function execute()
{
$fields = array_map(fn($field) => $this->stringForField($field), $this->fields);

$primary = array_filter($this->fields, fn($field) => $field instanceof IdField);
$primaryKey = isset($primary[0]) ? "PRIMARY KEY (`{$primary[0]->name}`)" : '';

if ($this->type === 'create') {
$fields = join(PHP_EOL, array_map(fn($field) => "{$field},", $fields));

$query = "
CREATE TABLE `{$this->table}` (
{$fields}
{$primaryKey}
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
";
}

if ($this->type === 'alter') {
$fields = join(PHP_EOL, array_map(fn($field) => "{$field};", $fields));
$drops = join(PHP_EOL, array_map(fn($drop) => "DROP COLUMN `{$drop}`;", $this->drops));

$query = "
ALTER TABLE `{$this->table}`
{$fields}
{$drops}
";
}

$statement = $this->connection->pdo()->prepare($query);
$statement->execute();
}

private function stringForField(Field $field): string
{
$prefix = '';

if ($this->type === 'alter') {
$prefix = 'ADD';
}

if ($field->alter) {
$prefix = 'MODIFY';
}

if ($field instanceof BoolField) {
$template = "{$prefix} `{$field->name}` tinyint(4)";

if ($field->nullable) {
$template .= " DEFAULT NULL";
}
if ($field->default !== null) {
$default = (int) $field->default;
$template .= " DEFAULT {$default}";
}

return $template;
}

if ($field instanceof DateTimeField) {
$template = "{$prefix} `{$field->name}` datetime";

if ($field->nullable) {
$template .= " DEFAULT NULL";
}
if ($field->default === 'CURRENT_TIMESTAMP') {
$template .= " DEFAULT CURRENT_TIMESTAMP";
} else if ($field->default !== null) {
$template .= " DEFAULT '{$field->default}'";
}

return $template;
}

if ($field instanceof FloatField) {
$template = "{$prefix} `{$field->name}` float";

if ($field->nullable) {
$template .= " DEFAULT NULL";
}
if ($field->default !== null) {
$template .= " DEFAULT '{$field->default}'";
}

return $template;
}

if ($field instanceof IdField) {
return "{$prefix} `{$field->name}` int(11) unsigned NOT NULL AUTO_INCREMENT";
}

if ($field instanceof IntField) {
$template = "{$prefix} `{$field->name}` int(11)";

if ($field->nullable) {
$template .= " DEFAULT NULL";
}
if ($field->default !== null) {
$template .= " DEFAULT '{$field->default}'";
}

return $template;
}

if ($field instanceof StringField) {
$template = "{$prefix} `{$field->name}` varchar(255)";

if ($field->nullable) {
$template .= " DEFAULT NULL";
}
if ($field->default !== null) {
$template .= " DEFAULT '{$field->default}'";
}

return $template;
}

if ($field instanceof TextField) {
return "{$prefix} `{$field->name}` text";
}

throw new MigrationException("Unrecognised field type for {$field->name}");
}

public function dropColumn(string $name): static
{
$this->drops[] = $name;
return $this;
}
}

+ 239
- 0
framework/Database/Migration/SqliteMigration.php Vedi File

@@ -0,0 +1,239 @@
<?php

namespace Framework\Database\Migration;

use Framework\Database\Connection\SqliteConnection;
use Framework\Database\Exception\MigrationException;
use Framework\Database\Migration\Field\Field;
use Framework\Database\Migration\Field\BoolField;
use Framework\Database\Migration\Field\DateTimeField;
use Framework\Database\Migration\Field\FloatField;
use Framework\Database\Migration\Field\IdField;
use Framework\Database\Migration\Field\IntField;
use Framework\Database\Migration\Field\StringField;
use Framework\Database\Migration\Field\TextField;

class SqliteMigration extends Migration
{
protected SqliteConnection $connection;
protected string $table;
protected string $type;
protected array $drops = [];

public function __construct(SqliteConnection $connection, string $table, string $type)
{
$this->connection = $connection;
$this->table = $table;
$this->type = $type;
}

public function execute()
{
if ($this->type === 'create') {
$this->executeCreate();
return;
}

$hasAlteredField = array_reduce($this->fields, fn($carry, $field) => $carry || $field->alter, false);

if (count($this->drops) > 0 || $hasAlteredField) {
$this->executeRebuild();
return;
}

$this->executeAdd();
}

private function executeCreate()
{
$fields = join(',' . PHP_EOL, array_map(fn($field) => $this->columnDefinition($field), $this->fields));

$query = "
CREATE TABLE \"{$this->table}\" (
{$fields}
);
";

$this->connection->pdo()->prepare($query)->execute();
}

private function executeAdd()
{
$fields = join(';' . PHP_EOL, array_map(
fn($field) => "ADD COLUMN {$this->columnDefinition($field)}",
$this->fields
));

$query = "
ALTER TABLE \"{$this->table}\"
{$fields};
";

$this->connection->pdo()->prepare($query)->execute();
}

/**
* SQLite can't alter or drop columns in place, so the standard approach is to
* rebuild the table: create a new table with the desired schema, copy the
* surviving data across, then swap it in for the original.
*/
private function executeRebuild()
{
$pdo = $this->connection->pdo();

$existingColumns = $pdo->query("PRAGMA table_info(\"{$this->table}\")")->fetchAll();

$alteredFields = [];
foreach ($this->fields as $field) {
if ($field->alter) {
$alteredFields[$field->name] = $field;
}
}

$newFields = array_filter($this->fields, fn($field) => !$field->alter);

$keptColumnNames = [];
$definitions = [];

foreach ($existingColumns as $column) {
$name = $column['name'];

if (in_array($name, $this->drops)) {
continue;
}

$keptColumnNames[] = $name;

$definitions[] = isset($alteredFields[$name])
? $this->columnDefinition($alteredFields[$name])
: $this->existingColumnDefinition($column);
}

foreach ($newFields as $field) {
$definitions[] = $this->columnDefinition($field);
}

$tempTable = "{$this->table}_migration_tmp";
$definitionList = join(',' . PHP_EOL, $definitions);
$columnList = join(', ', array_map(fn($name) => "\"{$name}\"", $keptColumnNames));

$pdo->beginTransaction();

$pdo->exec("CREATE TABLE \"{$tempTable}\" ({$definitionList})");
$pdo->exec("INSERT INTO \"{$tempTable}\" ({$columnList}) SELECT {$columnList} FROM \"{$this->table}\"");
$pdo->exec("DROP TABLE \"{$this->table}\"");
$pdo->exec("ALTER TABLE \"{$tempTable}\" RENAME TO \"{$this->table}\"");

$pdo->commit();
}

private function existingColumnDefinition(array $column): string
{
$name = $column['name'];
$type = $column['type'];

if ($column['pk'] == 1 && $type === 'INTEGER') {
return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT";
}

$definition = "\"{$name}\" {$type}";

if ($column['notnull'] == 1) {
$definition .= " NOT NULL";
}

if ($column['dflt_value'] !== null) {
$definition .= " DEFAULT {$column['dflt_value']}";
}

return $definition;
}

private function columnDefinition(Field $field): string
{
if ($field instanceof BoolField) {
$template = "\"{$field->name}\" INTEGER";

if (!$field->nullable) {
$template .= " NOT NULL";
}

if ($field->default !== null) {
$default = (int) $field->default;
$template .= " DEFAULT {$default}";
}

return $template;
}

if ($field instanceof DateTimeField) {
$template = "\"{$field->name}\" TEXT";

if (!$field->nullable) {
$template .= " NOT NULL";
}

if ($field->default === 'CURRENT_TIMESTAMP') {
$template .= " DEFAULT CURRENT_TIMESTAMP";
} else if ($field->default !== null) {
$template .= " DEFAULT '{$field->default}'";
}

return $template;
}

if ($field instanceof FloatField) {
$template = "\"{$field->name}\" REAL";

if (!$field->nullable) {
$template .= " NOT NULL";
}

if ($field->default !== null) {
$template .= " DEFAULT {$field->default}";
}

return $template;
}

if ($field instanceof IdField) {
return "\"{$field->name}\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE";
}

if ($field instanceof IntField) {
$template = "\"{$field->name}\" INTEGER";

if (!$field->nullable) {
$template .= " NOT NULL";
}

if ($field->default !== null) {
$template .= " DEFAULT {$field->default}";
}

return $template;
}

if ($field instanceof StringField || $field instanceof TextField) {
$template = "\"{$field->name}\" TEXT";

if (!$field->nullable) {
$template .= " NOT NULL";
}

if ($field->default !== null) {
$template .= " DEFAULT '{$field->default}'";
}

return $template;
}

throw new MigrationException("Unrecognised field type for {$field->name}");
}

public function dropColumn(string $name): static
{
$this->drops[] = $name;
return $this;
}
}

+ 189
- 0
framework/Database/Model.php Vedi File

@@ -0,0 +1,189 @@
<?php

namespace Framework\Database;

use Exception;
use Framework\Database\Connection\Connection;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;
use Framework\Database\Exception\ConnectionException;
use ReflectionClass;

abstract class Model
{
protected Connection $connection;
protected string $table;
protected array $attributes = [];
protected array $dirty = [];
protected array $casts = [];

public function setConnection(Connection $connection): static
{
$this->connection = $connection;
return $this;
}

public function getConnection(): Connection
{
if (!isset($this->connection)) {
$this->connection = app('database');
}

return $this->connection;
}

public function setTable(string $table): static
{
$this->table = $table;
return $this;
}

public function getTable(): string
{
if (!isset($this->table)) {
$reflector = new ReflectionClass(static::class);

foreach ($reflector->getAttributes() as $attribute) {
if ($attribute->getName() == TableName::class) {
return $attribute->getArguments()[0];
}
}

throw new Exception('$table is not set and getTable is not defined');
}

return $this->table;
}

public static function with(array $attributes = []): static
{
$model = new static();
$model->attributes = $attributes;

return $model;
}

public static function query(): mixed
{
$model = new static();
$query = $model->getConnection()->query();

return (new ModelCollector($query, static::class))
->from($model->getTable());
}

public static function __callStatic(string $method, array $parameters = []): mixed
{
return static::query()->$method(...$parameters);
}

public function __get(string $property): mixed
{
$getter = 'get' . ucfirst($property) . 'Attribute';

$value = null;

if (method_exists($this, $property)) {
$relationship = $this->$property();
$method = $relationship->method;

$value = $relationship->$method();
}

if (method_exists($this, $getter)) {
$value = $this->$getter($this->attributes[$property] ?? null);
}

if (isset($this->attributes[$property])) {
$value = $this->attributes[$property];
}

if (isset($this->casts[$property]) && is_callable($this->casts[$property])) {
$value = $this->casts[$property]($value);
}

return $value;
}

public function __set(string $property, $value)
{
$setter = 'set' . ucfirst($property) . 'Attribute';

array_push($this->dirty, $property);

if (method_exists($this, $setter)) {
$this->attributes[$property] = $this->$setter($value);
return;
}

$this->attributes[$property] = $value;
}

public function save(): static
{
$values = [];

foreach ($this->dirty as $dirty) {
$values[$dirty] = $this->attributes[$dirty];
}

$data = [array_keys($values), $values];

$query = static::query();

if (isset($this->attributes['id'])) {
$query
->where('id', $this->attributes['id'])
->update(...$data);

return $this;
}

$query->insert(...$data);

$this->attributes['id'] = $query->getLastInsertId();
$this->dirty = [];

return $this;
}

public function delete(): static
{
if (isset($this->attributes['id'])) {
static::query()
->where('id', $this->attributes['id'])
->delete();
}

return $this;
}

public function hasOne(string $class, string $foreignKey, string $primaryKey = 'id'): mixed
{
$model = new $class;
$query = $class::query()->from($model->getTable())->where($foreignKey, $this->attributes['id']);

return new Relationship($query, 'first');
}

public function hasMany(string $class, string $foreignKey, string $primaryKey = 'id'): mixed
{
$model = new $class;
$query = $class::query()->from($model->getTable())->where($foreignKey, $this->attributes['id']);

return new Relationship($query, 'all');
}

public function belongsTo(string $class, string $foreignKey, string $primaryKey = 'id'): mixed
{
$model = new $class;
$query = $class::query()->from($model->getTable())->where($primaryKey, $this->attributes[$foreignKey]);

return new Relationship($query, 'first');
}

public static function find(int $id): static
{
return static::where('id', $id)->first();
}
}

+ 56
- 0
framework/Database/ModelCollector.php Vedi File

@@ -0,0 +1,56 @@
<?php

namespace Framework\Database;

use Framework\Database\QueryBuilder\QueryBuilder;

class ModelCollector
{
private QueryBuilder $builder;
private string $class;

public function __construct(QueryBuilder $builder, string $class)
{
$this->builder = $builder;
$this->class = $class;
}

public function __call(string $method, array $parameters = []): mixed
{
$result = $this->builder->$method(...$parameters);

// in case it's a fluent method...
if ($result instanceof QueryBuilder) {
$this->builder = $result;
return $this;
}

return $result;
}

public function first()
{
$class = $this->class;

$row = $this->builder->first();

if (!is_null($row)) {
$row = $class::with($row);
}

return $row;
}

public function all()
{
$class = $this->class;

$rows = $this->builder->all();

foreach ($rows as $i => $row) {
$rows[$i] = $class::with($row);
}

return $rows;
}
}

+ 15
- 0
framework/Database/QueryBuilder/MysqlQueryBuilder.php Vedi File

@@ -0,0 +1,15 @@
<?php

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\MysqlConnection;

class MysqlQueryBuilder extends QueryBuilder
{
protected MysqlConnection $connection;

public function __construct(MysqlConnection $connection)
{
$this->connection = $connection;
}
}

+ 307
- 0
framework/Database/QueryBuilder/QueryBuilder.php Vedi File

@@ -0,0 +1,307 @@
<?php

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\Connection;
use Framework\Database\Exception\QueryException;
use Pdo;
use PdoStatement;

abstract class QueryBuilder
{
protected string $type;
protected array $columns;
protected string $table;
protected int $limit;
protected int $offset;
protected array $values;
protected array $wheres = [];

/**
* Fetch all rows matching the current query
*/
public function all(): array
{
if (!isset($this->type)) {
$this->select();
}

$statement = $this->prepare();
$statement->execute($this->getWhereValues());

return $statement->fetchAll(Pdo::FETCH_ASSOC);
}

/**
* Get the values for the where clause placeholders
*/
protected function getWhereValues(): array
{
$values = [];

if (count($this->wheres) === 0) {
return $values;
}

foreach ($this->wheres as $where) {
if (is_bool($where[2])) {
$values[$where[0]] = (int) $where[2];
continue;
}

$values[$where[0]] = $where[2];
}

return $values;
}

/**
* Prepare a query against a particular connection
*/
public function prepare(): PdoStatement
{
$query = '';

if ($this->type === 'select') {
$query = $this->compileSelect($query);
$query = $this->compileWheres($query);
$query = $this->compileLimit($query);
}

if ($this->type === 'insert') {
$query = $this->compileInsert($query);
}

if ($this->type === 'update') {
$query = $this->compileUpdate($query);
$query = $this->compileWheres($query);
}

if ($this->type === 'delete') {
$query = $this->compileDelete($query);
$query = $this->compileWheres($query);
}

if (empty($query)) {
throw new QueryException('Unrecognised query type');
}

return $this->connection->pdo()->prepare($query);
}

/**
* Add select clause to the query
*/
protected function compileSelect(string $query): string
{
$joinedColumns = join(', ', $this->columns);

$query .= " SELECT {$joinedColumns} FROM {$this->table}";

return $query;
}

/**
* Add limit and offset clauses to the query
*/
protected function compileLimit(string $query): string
{
if (isset($this->limit)) {
$query .= " LIMIT {$this->limit}";
}

if (isset($this->offset)) {
$query .= " OFFSET {$this->offset}";
}

return $query;
}

/**
* Add where clauses to the query
*/
protected function compileWheres(string $query): string
{
if (count($this->wheres) === 0) {
return $query;
}

$query .= ' WHERE';

foreach ($this->wheres as $i => $where) {
if ($i > 0) {
$query .= ' AND ';
}

[$column, $comparator, $value] = $where;

$query .= " {$column} {$comparator} :{$column}";
}

return $query;
}

/**
* Add insert clause to the query
*/
protected function compileInsert(string $query): string
{
$joinedColumns = join(', ', $this->columns);
$joinedPlaceholders = join(', ', array_map(fn($column) => ":{$column}", $this->columns));

$query .= " INSERT INTO {$this->table} ({$joinedColumns}) VALUES ({$joinedPlaceholders})";

return $query;
}

/**
* Add update clause to the query
*/
protected function compileUpdate(string $query): string
{
$joinedColumns = '';

foreach ($this->columns as $i => $column) {
if ($i > 0) {
$joinedColumns .= ', ';
}

$joinedColumns = " {$column} = :{$column}";
}

$query .= " UPDATE {$this->table} SET {$joinedColumns}";

return $query;
}

/**
* Add delete clause to the query
*/
protected function compileDelete(string $query): string
{
$query .= " DELETE FROM {$this->table}";
return $query;
}

/**
* Fetch the first row matching the current query
*/
public function first(): ?array
{
if (!isset($this->type)) {
$this->select();
}

$statement = $this->take(1)->prepare();
$statement->execute($this->getWhereValues());

$result = $statement->fetchAll(Pdo::FETCH_ASSOC);

if (count($result) === 1) {
return $result[0];
}

return null;
}

/**
* Limit a set of query results so that it's possible
* to fetch a single or limited batch of rows
*/
public function take(int $limit, int $offset = 0): static
{
$this->limit = $limit;
$this->offset = $offset;

return $this;
}

/**
* Indicate which table the query is targetting
*/
public function from(string $table): static
{
$this->table = $table;
return $this;
}

/**
* Indicate the query type is a "select" and remember
* which fields should be returned by the query
*/
public function select(mixed $columns = '*'): static
{
if (is_string($columns)) {
$columns = [$columns];
}

$this->type = 'select';
$this->columns = $columns;

return $this;
}

/**
* Insert a row of data into the table specified in the query
* and return the number of affected rows
*/
public function insert(array $columns, array $values): int
{
$this->type = 'insert';
$this->columns = $columns;
$this->values = $values;

$statement = $this->prepare();

return $statement->execute($values);
}

/**
* Store where clause data for later queries
*/
public function where(string $column, mixed $comparator, mixed $value = null): static
{
if (is_null($value) && !is_null($comparator)) {
array_push($this->wheres, [$column, '=', $comparator]);
} else {
array_push($this->wheres, [$column, $comparator, $value]);
}

return $this;
}

/**
* Insert a row of data into the table specified in the query
* and return the number of affected rows
*/
public function update(array $columns, array $values): int
{
$this->type = 'update';
$this->columns = $columns;
$this->values = $values;

$statement = $this->prepare();

return $statement->execute($this->getWhereValues() + $values);
}

/**
* Get the ID of the last row that was inserted
*/
public function getLastInsertId(): string
{
return $this->connection->pdo()->lastInsertId();
}

/**
* Delete a row from the database
*/
public function delete(): int
{
$this->type = 'delete';

$statement = $this->prepare();

return $statement->execute($this->getWhereValues());
}
}

+ 15
- 0
framework/Database/QueryBuilder/SqliteQueryBuilder.php Vedi File

@@ -0,0 +1,15 @@
<?php

namespace Framework\Database\QueryBuilder;

use Framework\Database\Connection\SqliteConnection;

class SqliteQueryBuilder extends QueryBuilder
{
protected SqliteConnection $connection;

public function __construct(SqliteConnection $connection)
{
$this->connection = $connection;
}
}

+ 27
- 0
framework/Database/Relationship.php Vedi File

@@ -0,0 +1,27 @@
<?php

namespace Framework\Database;

use Framework\Database\ModelCollector;

class Relationship
{
public ModelCollector $collector;
public string $method;

public function __construct(ModelCollector $collector, string $method)
{
$this->collector = $collector;
$this->method = $method;
}

public function __invoke(array $parameters = []): mixed
{
return $this->collector->$method(...$parameters);
}

public function __call(string $method, array $parameters = []): mixed
{
return $this->collector->$method(...$parameters);
}
}

+ 14
- 0
framework/Database/TableName.php Vedi File

@@ -0,0 +1,14 @@
<?php

namespace Framework\Database;

#[Attribute]
class TableName
{
public string $name;

public function __construct(string $name)
{
$this->name = $name;
}
}

+ 12
- 0
framework/Email/Driver/Driver.php Vedi File

@@ -0,0 +1,12 @@
<?php

namespace Framework\Email\Driver;

interface Driver
{
public function to(string $to): static;
public function subject(string $subject): static;
public function text(string $text): static;
public function html(string $html): static;
public function send(): void;
}

+ 93
- 0
framework/Email/Driver/PostmarkDriver.php Vedi File

@@ -0,0 +1,93 @@
<?php

namespace Framework\Email\Driver;

use Framework\Email\Exception\CompositionException;
use Postmark\Transport;
use Swift_Mailer;
use Swift_Message;

class PostmarkDriver implements Driver
{
private array $config;
private Swift_Mailer $mailer;
private string $to;
private string $subject;
private string $text;
private string $html;

public function __construct(array $config)
{
$this->config = $config;
}

public function to(string $to): static
{
$this->to = $to;
return $this;
}

public function subject(string $subject): static
{
$this->subject = $subject;
return $this;
}

public function text(string $text): static
{
$this->text = $text;
return $this;
}

public function html(string $html): static
{
$this->html = $html;
return $this;
}

public function send(): void
{
if (!isset($this->to)) {
throw new CompositionException('to required');
}

if (!isset($this->text) && !isset($this->html)) {
throw new CompositionException('text or email required');
}

$fromName = $this->config['from']['name'];
$fromEmail = $this->config['from']['email'];

$subject = $this->subject ?? "Message from {$fromName}";

$message = (new Swift_Message($subject))
->setFrom([$fromEmail => $fromName])
->setTo([$this->to]);

if (isset($this->text) && !isset($this->html)) {
$message->setBody($this->text, 'text/plain');
}

if (!isset($this->text) && isset($this->html)) {
$message->setBody($this->html, 'text/html');
}

if (isset($this->text, $this->html)) {
$message
->setBody($this->html, 'text/html')
->addPart($this->text, 'text/plain');
}

$this->mailer()->send($message);
}

private function mailer()
{
if (!isset($this->mailer)) {
$transport = new Transport($this->config['token']);
$this->mailer = new Swift_Mailer($transport);
}

return $this->mailer;
}
}

+ 9
- 0
framework/Email/Exception/CompositionException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Email\Exception;

use InvalidArgumentException;

class CompositionException extends InvalidArgumentException
{
}

+ 9
- 0
framework/Email/Exception/DriverException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Email\Exception;

use RuntimeException;

class DriverException extends RuntimeException
{
}

+ 34
- 0
framework/Email/Factory.php Vedi File

@@ -0,0 +1,34 @@
<?php

namespace Framework\Email;

use Closure;
use Framework\Email\Driver\Driver;
use Framework\Email\Exception\DriverException;
use Framework\Support\DriverFactory;

class Factory implements DriverFactory
{
protected array $drivers;

public function addDriver(string $alias, Closure $driver): static
{
$this->drivers[$alias] = $driver;
return $this;
}

public function connect(array $config): Driver
{
if (!isset($config['type'])) {
throw new DriverException('type is not defined');
}

$type = $config['type'];

if (isset($this->drivers[$type])) {
return $this->drivers[$type]($config);
}

throw new DriverException('unrecognised type');
}
}

+ 44
- 0
framework/Filesystem/Driver/Driver.php Vedi File

@@ -0,0 +1,44 @@
<?php

namespace Framework\Filesystem\Driver;

use League\Flysystem\Filesystem;

abstract class Driver
{
protected Filesystem $filesystem;

public function __construct(array $config)
{
$this->filesystem = $this->connect($config);
}

abstract protected function connect(array $config): Filesystem;

public function list(string $path, bool $recursive = false): iterable
{
return $this->filesystem->listContents($path, $recursive);
}

public function exists(string $path): bool
{
return $this->filesystem->fileExists($path);
}

public function get(string $path): string
{
return $this->filesystem->read($path);
}

public function put(string $path, mixed $value): static
{
$this->filesystem->write($path, $value);
return $this;
}

public function delete(string $path): static
{
$this->filesystem->delete($path);
return $this;
}
}

+ 16
- 0
framework/Filesystem/Driver/LocalDriver.php Vedi File

@@ -0,0 +1,16 @@
<?php

namespace Framework\Filesystem\Driver;

use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;

class LocalDriver extends Driver
{
protected function connect(array $config): Filesystem
{
$adapter = new LocalFilesystemAdapter($config['path']);

return new Filesystem($adapter);
}
}

+ 9
- 0
framework/Filesystem/Exception/DriverException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Filesystem\Exception;

use RuntimeException;

class DriverException extends RuntimeException
{
}

+ 34
- 0
framework/Filesystem/Factory.php Vedi File

@@ -0,0 +1,34 @@
<?php

namespace Framework\Filesystem;

use Closure;
use Framework\Filesystem\Driver\Driver;
use Framework\Filesystem\Exception\DriverException;
use Framework\Support\DriverFactory;

class Factory implements DriverFactory
{
protected array $drivers;

public function addDriver(string $alias, Closure $driver): static
{
$this->drivers[$alias] = $driver;
return $this;
}

public function connect(array $config): Driver
{
if (!isset($config['type'])) {
throw new DriverException('type is not defined');
}

$type = $config['type'];

if (isset($this->drivers[$type])) {
return $this->drivers[$type]($config);
}

throw new DriverException('unrecognised type');
}
}

+ 104
- 0
framework/Http/Response.php Vedi File

@@ -0,0 +1,104 @@
<?php

namespace Framework\Http;

use InvalidArgumentException;

class Response
{
const REDIRECT = 'REDIRECT';
const HTML = 'HTML';
const JSON = 'JSON';

private string $type = 'HTML';
private ?string $redirect = null;
private mixed $content = '';
private int $status = 200;
private array $headers = [];

public function content(mixed $content = null): mixed
{
if (is_null($content)) {
return $this->content;
}

$this->content = $content;

return $this;
}

public function status(int $status = null): int|static
{
if (is_null($status)) {
return $this->status;
}

$this->status = $status;

return $this;
}

public function header(string $key, string $value): static
{
$this->headers[$key] = $value;
return $this;
}

public function redirect(string $redirect = null): mixed
{
if (is_null($redirect)) {
return $this->redirect;
}

$this->redirect = $redirect;
$this->type = static::REDIRECT;
return $this;
}

public function json(mixed $content): static
{
$this->content = $content;
$this->type = static::JSON;
return $this;
}

public function type(string $type = null): string|static
{
if (is_null($type)) {
return $this->type;
}

$this->type = $type;

return $this;
}

public function send(): void
{
foreach ($this->headers as $key => $value) {
header("{$key}: {$value}");
}

if ($this->type === static::HTML) {
header('Content-Type: text/html');
http_response_code($this->status);
print $this->content;
return;
}

if ($this->type === static::JSON) {
header('Content-Type: application/json');
http_response_code($this->status);
print json_encode($this->content);
return;
}


if ($this->type === static::REDIRECT) {
header("Location: {$this->redirect}");
return;
}

throw new InvalidArgumentException("{$this->type} is not a recognised type");
}
}

+ 10
- 0
framework/Logging/Driver/Driver.php Vedi File

@@ -0,0 +1,10 @@
<?php

namespace Framework\Logging\Driver;

interface Driver
{
public function info(string $message): static;
public function warning(string $message): static;
public function error(string $message): static;
}

+ 44
- 0
framework/Logging/Driver/StreamDriver.php Vedi File

@@ -0,0 +1,44 @@
<?php

namespace Framework\Logging\Driver;

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

class StreamDriver implements Driver
{
private array $config;

public function __construct(array $config)
{
$this->config = $config;
}

public function info(string $message): static
{
$this->logger()->info($message);
return $this;
}

private function logger()
{
if (!isset($this->logger)) {
$this->logger = new Logger($this->config['name']);
$this->logger->pushHandler(new StreamHandler($this->config['path'], $this->config['minimum']));
}

return $this->logger;
}

public function warning(string $message): static
{
$this->logger()->warning($message);
return $this;
}

public function error(string $message): static
{
$this->logger()->error($message);
return $this;
}
}

+ 9
- 0
framework/Logging/Exception/DriverException.php Vedi File

@@ -0,0 +1,9 @@
<?php

namespace Framework\Logging\Exception;

use RuntimeException;

class DriverException extends RuntimeException
{
}

+ 34
- 0
framework/Logging/Factory.php Vedi File

@@ -0,0 +1,34 @@
<?php

namespace Framework\Logging;

use Closure;
use Framework\Logging\Driver\Driver;
use Framework\Logging\Exception\DriverException;
use Framework\Support\DriverFactory;

class Factory implements DriverFactory
{
protected array $drivers;

public function addDriver(string $alias, Closure $driver): static
{
$this->drivers[$alias] = $driver;
return $this;
}

public function connect(array $config): Driver
{
if (!isset($config['type'])) {
throw new DriverException('type is not defined');
}

$type = $config['type'];

if (isset($this->drivers[$type])) {
return $this->drivers[$type]($config);
}

throw new DriverException('unrecognised type');
}
}

+ 38
- 0
framework/Provider/CacheProvider.php Vedi File

@@ -0,0 +1,38 @@
<?php

namespace Framework\Provider;

use Framework\Cache\Factory;
use Framework\Cache\Driver\FileDriver;
use Framework\Cache\Driver\MemcacheDriver;
use Framework\Cache\Driver\MemoryDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class CacheProvider extends DriverProvider
{
protected function name(): string
{
return 'cache';
}

protected function factory(): DriverFactory
{
return new Factory();
}

protected function drivers(): array
{
return [
'file' => function($config) {
return new FileDriver($config);
},
'memcache' => function($config) {
return new MemcacheDriver($config);
},
'memory' => function($config) {
return new MemoryDriver($config);
},
];
}
}

+ 16
- 0
framework/Provider/ConfigProvider.php Vedi File

@@ -0,0 +1,16 @@
<?php

namespace Framework\Provider;

use Framework\App;
use Framework\Support\Config;

class ConfigProvider
{
public function bind(App $app): void
{
$app->bind('config', function($app) {
return new Config();
});
}
}

+ 34
- 0
framework/Provider/DatabaseProvider.php Vedi File

@@ -0,0 +1,34 @@
<?php

namespace Framework\Provider;

use Framework\Database\Factory;
use Framework\Database\Connection\MysqlConnection;
use Framework\Database\Connection\SqliteConnection;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class DatabaseProvider extends DriverProvider
{
protected function name(): string
{
return 'database';
}

protected function factory(): DriverFactory
{
return new Factory();
}

protected function drivers(): array
{
return [
'sqlite' => function($config) {
return new SqliteConnection($config);
},
'mysql' => function($config) {
return new MysqlConnection($config);
},
];
}
}

+ 30
- 0
framework/Provider/EmailProvider.php Vedi File

@@ -0,0 +1,30 @@
<?php

namespace Framework\Provider;

use Framework\Email\Factory;
use Framework\Email\Driver\PostmarkDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class EmailProvider extends DriverProvider
{
protected function name(): string
{
return 'email';
}

protected function factory(): DriverFactory
{
return new Factory();
}

protected function drivers(): array
{
return [
'postmark' => function($config) {
return new PostmarkDriver($config);
},
];
}
}

+ 30
- 0
framework/Provider/FilesystemProvider.php Vedi File

@@ -0,0 +1,30 @@
<?php

namespace Framework\Provider;

use Framework\Filesystem\Factory;
use Framework\Filesystem\Driver\LocalDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class FilesystemProvider extends DriverProvider
{
protected function name(): string
{
return 'filesystem';
}

protected function factory(): DriverFactory
{
return new Factory();
}

protected function drivers(): array
{
return [
'local' => function($config) {
return new LocalDriver($config);
},
];
}
}

+ 30
- 0
framework/Provider/LoggingProvider.php Vedi File

@@ -0,0 +1,30 @@
<?php

namespace Framework\Provider;

use Framework\Logging\Factory;
use Framework\Logging\Driver\StreamDriver;
use Framework\Support\DriverProvider;
use Framework\Support\DriverFactory;

class LoggingProvider extends DriverProvider
{
protected function name(): string
{
return 'logging';
}

protected function factory(): DriverFactory
{
return new Factory();
}

protected function drivers(): array
{
return [
'stream' => function($config) {
return new StreamDriver($config);
},
];
}
}

Dato che sono stati cambiati molti file in questo diff, alcuni di essi non verranno mostrati

Loading…
Annulla
Salva

Powered by TurnKey Linux.