commit dae758b140f26cf2cedc8833e1b260c78bd45cf1 Author: Daniel Covington Date: Tue Jul 21 08:51:17 2026 -0400 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e22b62 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64a1fa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +database/*.sqlite +drivers/* +vendor +*.cache diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d312c0e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php new file mode 100644 index 0000000..0972882 --- /dev/null +++ b/app/Exceptions/Handler.php @@ -0,0 +1,16 @@ +put('ordered', true); + + return redirect($router->route('show-home-page')); + } +} diff --git a/app/Http/Controllers/Products/ShowProductController.php b/app/Http/Controllers/Products/ShowProductController.php new file mode 100644 index 0000000..2f4ed3d --- /dev/null +++ b/app/Http/Controllers/Products/ShowProductController.php @@ -0,0 +1,22 @@ +current()->parameters(); + + $product = Product::find((int) $parameters['product']); + + return view('products/view', [ + 'product' => $product, + 'orderAction' => $router->route('order-product', ['product' => $product->id]), + 'csrf' => csrf(), + ]); + } +} diff --git a/app/Http/Controllers/ShowHomePageController.php b/app/Http/Controllers/ShowHomePageController.php new file mode 100644 index 0000000..2309977 --- /dev/null +++ b/app/Http/Controllers/ShowHomePageController.php @@ -0,0 +1,46 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Users/LogInUserController.php b/app/Http/Controllers/Users/LogInUserController.php new file mode 100644 index 0000000..028500b --- /dev/null +++ b/app/Http/Controllers/Users/LogInUserController.php @@ -0,0 +1,27 @@ + ['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')); + } +} diff --git a/app/Http/Controllers/Users/LogOutUserController.php b/app/Http/Controllers/Users/LogOutUserController.php new file mode 100644 index 0000000..185f31d --- /dev/null +++ b/app/Http/Controllers/Users/LogOutUserController.php @@ -0,0 +1,15 @@ +forget('user_id'); + + return redirect($router->route('show-home-page')); + } +} diff --git a/app/Http/Controllers/Users/RegisterUserController.php b/app/Http/Controllers/Users/RegisterUserController.php new file mode 100644 index 0000000..d201f28 --- /dev/null +++ b/app/Http/Controllers/Users/RegisterUserController.php @@ -0,0 +1,34 @@ + ['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')); + } +} diff --git a/app/Http/Controllers/Users/ShowRegisterFormController.php b/app/Http/Controllers/Users/ShowRegisterFormController.php new file mode 100644 index 0000000..1bec47e --- /dev/null +++ b/app/Http/Controllers/Users/ShowRegisterFormController.php @@ -0,0 +1,17 @@ + $router->route('register-user'), + 'logInAction' => $router->route('log-in-user'), + 'csrf' => csrf(), + ]); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..1b24a6d --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,15 @@ +belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..9baa7f0 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,27 @@ +belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..59596b1 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,20 @@ +hasOne(Profile::class, 'user_id'); + } + + public function orders(): mixed + { + return $this->hasMany(Order::class, 'user_id'); + } +} diff --git a/app/commands.php b/app/commands.php new file mode 100644 index 0000000..e3d7b9d --- /dev/null +++ b/app/commands.php @@ -0,0 +1,11 @@ +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'); +}; diff --git a/command.php b/command.php new file mode 100644 index 0000000..2673110 --- /dev/null +++ b/command.php @@ -0,0 +1,17 @@ +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(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2fe461c --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..003e755 --- /dev/null +++ b/composer.lock @@ -0,0 +1,4886 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "106b52a748c01bbdd43c9758984317f3", + "packages": [ + { + "name": "doctrine/lexer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "e864bbf5904cb8f5bb334f99209b48018522f042" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/e864bbf5904cb8f5bb334f99209b48018522f042", + "reference": "e864bbf5904cb8f5bb334f99209b48018522f042", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpstan/phpstan": "^0.11.8", + "phpunit/phpunit": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2020-05-25T17:44:05+00:00" + }, + { + "name": "egulias/email-validator", + "version": "2.1.25", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/0dbf5d78455d4d6a41d186da50adc1122ec066f4", + "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^1.0.1", + "php": ">=5.5", + "symfony/polyfill-intl-idn": "^1.10" + }, + "require-dev": { + "dominicsayers/isemail": "^3.0.7", + "phpunit/phpunit": "^4.8.36|^7.5.15", + "satooshi/php-coveralls": "^1.0.1" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/2.1.25" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2020-12-29T14:50:06+00:00" + }, + { + "name": "filp/whoops", + "version": "2.1.3", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "8828aaa2178e0a19325522e2a45282ff0a14649b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/8828aaa2178e0a19325522e2a45282ff0a14649b", + "reference": "8828aaa2178e0a19325522e2a45282ff0a14649b", + "shasum": "" + }, + "require": { + "php": ">=5.5.9" + }, + "require-dev": { + "mockery/mockery": "0.9.*", + "phpunit/phpunit": "^4.8 || ^5.0", + "symfony/var-dumper": "~3.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://github.com/filp/whoops", + "keywords": [ + "error", + "exception", + "handling", + "library", + "whoops", + "zf2" + ], + "time": "2016-05-06T18:25:35+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "7e279d2cd5d7fbb156ce46daada972355cea27bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/7e279d2cd5d7fbb156ce46daada972355cea27bb", + "reference": "7e279d2cd5d7fbb156ce46daada972355cea27bb", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0", + "phpoption/phpoption": "^1.7.3" + }, + "require-dev": { + "phpunit/phpunit": "^6.5|^7.5|^8.5|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2020-04-13T13:17:36+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "0aa74dfb41ae110835923ef10a9d803a22d50e79" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/0aa74dfb41ae110835923ef10a9d803a22d50e79", + "reference": "0aa74dfb41ae110835923ef10a9d803a22d50e79", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.4", + "guzzlehttp/psr7": "^1.7", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "ext-curl": "*", + "php-http/client-integration-tests": "^3.0", + "phpunit/phpunit": "^8.5.5 || ^9.3.5", + "psr/log": "^1.1" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.1-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://github.com/alexeyshockov", + "type": "github" + }, + { + "url": "https://github.com/gmponos", + "type": "github" + } + ], + "time": "2020-10-10T11:47:56+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "60d379c243457e073cff02bc323a2a86cb355631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/60d379c243457e073cff02bc323a2a86cb355631", + "reference": "60d379c243457e073cff02bc323a2a86cb355631", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.4 || ^5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.4.0" + }, + "time": "2020-09-30T07:37:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/53330f47520498c0ae1f61f7e2c90f55690c06a3", + "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0", + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-zlib": "*", + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.7-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/1.7.0" + }, + "time": "2020-09-30T07:37:11+00:00" + }, + { + "name": "league/flysystem", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "1d68c2325a56b6b847f3a40b9564cabfa7bb2594" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1d68c2325a56b6b847f3a40b9564cabfa7bb2594", + "reference": "1d68c2325a56b6b847f3a40b9564cabfa7bb2594", + "shasum": "" + }, + "require": { + "ext-json": "*", + "league/mime-type-detection": "^1.0.0", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "guzzlehttp/ringphp": "<1.1.1" + }, + "require-dev": { + "async-aws/s3": "^1.5", + "async-aws/simple-s3": "^1.0", + "aws/aws-sdk-php": "^3.132.4", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "friendsofphp/php-cs-fixer": "^2.16", + "google/cloud-storage": "^1.23", + "phpseclib/phpseclib": "^2.0", + "phpstan/phpstan": "^0.12.26", + "phpunit/phpunit": "^8.5 || ^9.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/2.0.2" + }, + "funding": [ + { + "url": "https://offset.earth/frankdejonge", + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2020-12-28T17:02:23+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3", + "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.18", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.7.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2021-01-18T20:58:21+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/1cb1cde8e8dd0f70cc0fe51354a59acad9302084", + "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1" + }, + "provide": { + "psr/log-implementation": "1.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7", + "graylog2/gelf-php": "^1.4.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4", + "php-console/php-console": "^3.1.3", + "phpspec/prophecy": "^1.6.1", + "phpstan/phpstan": "^0.12.59", + "phpunit/phpunit": "^8.5", + "predis/predis": "^1.1", + "rollbar/rollbar": "^1.3", + "ruflin/elastica": ">=0.90 <7.0.1", + "swiftmailer/swiftmailer": "^5.3|^6.0" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2020-12-14T13:15:25+00:00" + }, + { + "name": "opis/closure", + "version": "3.6.1", + "source": { + "type": "git", + "url": "https://github.com/opis/closure.git", + "reference": "943b5d70cc5ae7483f6aff6ff43d7e34592ca0f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/closure/zipball/943b5d70cc5ae7483f6aff6ff43d7e34592ca0f5", + "reference": "943b5d70cc5ae7483f6aff6ff43d7e34592ca0f5", + "shasum": "" + }, + "require": { + "php": "^5.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "jeremeamia/superclosure": "^2.0", + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Closure\\": "src/" + }, + "files": [ + "functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary objects.", + "homepage": "https://opis.io/closure", + "keywords": [ + "anonymous functions", + "closure", + "function", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/opis/closure/issues", + "source": "https://github.com/opis/closure/tree/3.6.1" + }, + "time": "2020-11-07T02:01:34+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.7.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "994ecccd8f3283ecf5ac33254543eb0ac946d525" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/994ecccd8f3283ecf5ac33254543eb0ac946d525", + "reference": "994ecccd8f3283ecf5ac33254543eb0ac946d525", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "phpunit/phpunit": "^4.8.35 || ^5.7.27 || ^6.5.6 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.7-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2020-07-20T17:29:33+00:00" + }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/master" + }, + "time": "2017-02-14T16:28:37+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client/tree/master" + }, + "time": "2020-06-29T06:28:15+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/master" + }, + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "psr/log", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.3" + }, + "time": "2020-03-23T09:12:05+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "swiftmailer/swiftmailer", + "version": "v6.2.5", + "source": { + "type": "git", + "url": "https://github.com/swiftmailer/swiftmailer.git", + "reference": "698a6a9f54d7eb321274de3ad19863802c879fb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/698a6a9f54d7eb321274de3ad19863802c879fb7", + "reference": "698a6a9f54d7eb321274de3ad19863802c879fb7", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.0", + "php": ">=7.0.0", + "symfony/polyfill-iconv": "^1.0", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "symfony/phpunit-bridge": "^4.4|^5.0" + }, + "suggest": { + "ext-intl": "Needed to support internationalized email addresses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.2-dev" + } + }, + "autoload": { + "files": [ + "lib/swift_required.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Corbyn" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Swiftmailer, free feature-rich PHP mailer", + "homepage": "https://swiftmailer.symfony.com", + "keywords": [ + "email", + "mail", + "mailer" + ], + "support": { + "issues": "https://github.com/swiftmailer/swiftmailer/issues", + "source": "https://github.com/swiftmailer/swiftmailer/tree/v6.2.5" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/swiftmailer/swiftmailer", + "type": "tidelift" + } + ], + "time": "2021-01-12T09:35:59+00:00" + }, + { + "name": "symfony/console", + "version": "v5.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e", + "reference": "e0b2c29c0fa6a69089209bbe8fcff4df2a313d0e", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2", + "symfony/string": "^5.1" + }, + "conflict": { + "symfony/dependency-injection": "<4.4", + "symfony/dotenv": "<5.1", + "symfony/event-dispatcher": "<4.4", + "symfony/lock": "<4.4", + "symfony/process": "<4.4" + }, + "provide": { + "psr/log-implementation": "1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/lock": "^4.4|^5.0", + "symfony/process": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/console/tree/v5.1.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T12:01:57+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.18.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "1c302646f6efc070cd46856e600e5e0684d6b454" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454", + "reference": "1c302646f6efc070cd46856e600e5e0684d6b454", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.18-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-07-14T12:35:20+00:00" + }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.22.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "b34bfb8c4c22650ac080d2662ae3502e5f2f4ae6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/b34bfb8c4c22650ac080d2662ae3502e5f2f4ae6", + "reference": "b34bfb8c4c22650ac080d2662ae3502e5f2f4ae6", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.22.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c", + "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.22.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44", + "reference": "0eb8293dbbcd6ef6bf81404c9ce7d95bcdf34f44", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "symfony/polyfill-intl-normalizer": "^1.10", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.22.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "727d1096295d807c309fb01a851577302394c897" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897", + "reference": "727d1096295d807c309fb01a851577302394c897", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.18.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "a6977d63bf9a0ad4c65cd352709e230876f9904a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/a6977d63bf9a0ad4c65cd352709e230876f9904a", + "reference": "a6977d63bf9a0ad4c65cd352709e230876f9904a", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.18-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-07-14T12:35:20+00:00" + }, + { + "name": "symfony/polyfill-php72", + "version": "v1.22.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php72.git", + "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9", + "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php72\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php72/tree/v1.22.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed", + "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.18.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "d87d5766cbf48d72388a9f6b85f280c8ad51f981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/d87d5766cbf48d72388a9f6b85f280c8ad51f981", + "reference": "d87d5766cbf48d72388a9f6b85f280c8ad51f981", + "shasum": "" + }, + "require": { + "php": ">=7.0.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.18-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-07-14T12:35:20+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/master" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-09-07T11:33:47+00:00" + }, + { + "name": "symfony/string", + "version": "v5.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "a97573e960303db71be0dd8fda9be3bca5e0feea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/a97573e960303db71be0dd8fda9be3bca5e0feea", + "reference": "a97573e960303db71be0dd8fda9be3bca5e0feea", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "~1.15" + }, + "require-dev": { + "symfony/error-handler": "^4.4|^5.0", + "symfony/http-client": "^4.4|^5.0", + "symfony/translation-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "files": [ + "Resources/functions.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony String component", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v5.1.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T12:01:57+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.2.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "fba64139db67123c7a57072e5f8d3db10d160b66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/fba64139db67123c7a57072e5f8d3db10d160b66", + "reference": "fba64139db67123c7a57072e5f8d3db10d160b66", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.0.1", + "php": "^7.1.3 || ^8.0", + "phpoption/phpoption": "^1.7.4", + "symfony/polyfill-ctype": "^1.17", + "symfony/polyfill-mbstring": "^1.17", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "ext-filter": "*", + "phpunit/phpunit": "^7.5.20 || ^8.5.2 || ^9.0" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.2-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "homepage": "https://gjcampbell.co.uk/" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://vancelucas.com/" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2020-09-14T15:57:31+00:00" + }, + { + "name": "wildbit/swiftmailer-postmark", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/wildbit/swiftmailer-postmark.git", + "reference": "44ccab7834de8b220d292647ecb2cb683f9962ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wildbit/swiftmailer-postmark/zipball/44ccab7834de8b220d292647ecb2cb683f9962ee", + "reference": "44ccab7834de8b220d292647ecb2cb683f9962ee", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0|^7.0", + "swiftmailer/swiftmailer": "^6.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Postmark\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Postmark", + "email": "support@postmarkapp.com" + } + ], + "description": "A Swiftmailer Transport for Postmark.", + "support": { + "issues": "https://github.com/wildbit/swiftmailer-postmark/issues", + "source": "https://github.com/wildbit/swiftmailer-postmark/tree/3.3.0" + }, + "time": "2020-09-10T10:54:20+00:00" + } + ], + "packages-dev": [ + { + "name": "dbrekelmans/bdi", + "version": "0.3", + "source": { + "type": "git", + "url": "https://github.com/dbrekelmans/bdi.git", + "reference": "d94d818ebe20b419a7d9f17dc21e9c66a10b171f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dbrekelmans/bdi/zipball/d94d818ebe20b419a7d9f17dc21e9c66a10b171f", + "reference": "d94d818ebe20b419a7d9f17dc21e9c66a10b171f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "ext-zlib": "*", + "php": "^7.2|^8.0" + }, + "bin": [ + "bdi", + "bdi.phar" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniël Brekelmans", + "homepage": "https://github.com/dbrekelmans" + }, + { + "name": "Contributors", + "homepage": "https://github.com/dbrekelmans/bdi/graphs/contributors" + } + ], + "description": "PHAR distribution of dbrekelmans/browser-driver-installer.", + "homepage": "https://github.com/dbrekelmans/bdi", + "keywords": [ + "browser-driver-installer" + ], + "support": { + "source": "https://github.com/dbrekelmans/bdi/tree/0.3" + }, + "time": "2020-12-13T21:43:40+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b", + "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^8.0", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.13 || 1.0.0-alpha2", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.4.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2020-11-10T18:47:58+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.10.2", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220", + "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "replace": { + "myclabs/deep-copy": "self.version" + }, + "require-dev": { + "doctrine/collections": "^1.0", + "doctrine/common": "^2.6", + "phpunit/phpunit": "^7.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + }, + "files": [ + "src/DeepCopy/deep_copy.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2020-11-13T09:40:50+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v4.10.4", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "c6d052fc58cb876152f89f532b95a8d7907e7f0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/c6d052fc58cb876152f89f532b95a8d7907e7f0e", + "reference": "c6d052fc58cb876152f89f532b95a8d7907e7f0e", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.10.4" + }, + "time": "2020-12-20T10:01:03+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/85265efd3af7ba3ca4b2a2c34dbfc5788dd29133", + "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/master" + }, + "time": "2020-06-27T14:33:11+00:00" + }, + { + "name": "phar-io/version", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "e4782611070e50613683d2b9a57730e9a3ba5451" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/e4782611070e50613683d2b9a57730e9a3ba5451", + "reference": "e4782611070e50613683d2b9a57730e9a3ba5451", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.0.4" + }, + "time": "2020-12-13T23:18:30+00:00" + }, + { + "name": "php-webdriver/webdriver", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "e3633154554605274cc9d59837f55a7427d72003" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/e3633154554605274cc9d59837f55a7427d72003", + "reference": "e3633154554605274cc9d59837f55a7427d72003", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^5.6 || ~7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^2.8 || ^3.1 || ^4.0 || ^5.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "ondram/ci-detector": "^2.1 || ^3.5", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^1.1 || ^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^5.7 || ^7 || ^8 || ^9", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^3.3 || ^4.0 || ^5.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + }, + "files": [ + "lib/Exception/TimeoutException.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.9.0" + }, + "time": "2020-11-19T15:21:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.2.2", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/069a785b2141f5bcf49f3e353548dc1cce6df556", + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.3", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "account@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/master" + }, + "time": "2020-09-03T19:13:55+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.4.0" + }, + "time": "2020-09-17T18:55:26+00:00" + }, + { + "name": "phpspec/prophecy", + "version": "1.12.2", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "245710e971a030f42e08f4912863805570f23d39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/245710e971a030f42e08f4912863805570f23d39", + "reference": "245710e971a030f42e08f4912863805570f23d39", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.2", + "php": "^7.2 || ~8.0, <8.1", + "phpdocumentor/reflection-docblock": "^5.2", + "sebastian/comparator": "^3.0 || ^4.0", + "sebastian/recursion-context": "^3.0 || ^4.0" + }, + "require-dev": { + "phpspec/phpspec": "^6.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Prophecy\\": "src/Prophecy" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/1.12.2" + }, + "time": "2020-12-19T10:15:11+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "f3e026641cc91909d421802dd3ac7827ebfd97e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f3e026641cc91909d421802dd3ac7827ebfd97e1", + "reference": "f3e026641cc91909d421802dd3ac7827ebfd97e1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.10.2", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.3", + "phpunit/php-text-template": "^2.0.2", + "sebastian/code-unit-reverse-lookup": "^2.0.2", + "sebastian/complexity": "^2.0", + "sebastian/environment": "^5.1.2", + "sebastian/lines-of-code": "^1.0.3", + "sebastian/version": "^3.0.1", + "theseer/tokenizer": "^1.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcov": "*", + "ext-xdebug": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-11-28T06:44:49+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/aa4be8575f26070b100fccb67faabb28f21f66f8", + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:57:25+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.5.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "8e16c225d57c3d6808014df6b1dd7598d0a5bbbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8e16c225d57c3d6808014df6b1dd7598d0a5bbbe", + "reference": "8e16c225d57c3d6808014df6b1dd7598d0a5bbbe", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.3.1", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.10.1", + "phar-io/manifest": "^2.0.1", + "phar-io/version": "^3.0.2", + "php": ">=7.3", + "phpspec/prophecy": "^1.12.1", + "phpunit/php-code-coverage": "^9.2.3", + "phpunit/php-file-iterator": "^3.0.5", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.3", + "phpunit/php-timer": "^5.0.2", + "sebastian/cli-parser": "^1.0.1", + "sebastian/code-unit": "^1.0.6", + "sebastian/comparator": "^4.0.5", + "sebastian/diff": "^4.0.3", + "sebastian/environment": "^5.1.3", + "sebastian/exporter": "^4.0.3", + "sebastian/global-state": "^5.0.1", + "sebastian/object-enumerator": "^4.0.3", + "sebastian/resource-operations": "^3.0.3", + "sebastian/type": "^2.3", + "sebastian/version": "^3.0.2" + }, + "require-dev": { + "ext-pdo": "*", + "phpspec/prophecy-phpunit": "^2.0.1" + }, + "suggest": { + "ext-soap": "*", + "ext-xdebug": "*" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.5-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ], + "files": [ + "src/Framework/Assert/Functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.0" + }, + "funding": [ + { + "url": "https://phpunit.de/donate.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-12-04T05:05:53+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:08:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55f4261989e546dc112258c7a75935a81a7ce382" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382", + "reference": "55f4261989e546dc112258c7a75935a81a7ce382", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:49:45+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.7", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:52:27+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:10:38+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "388b6ced16caa751030f6a69e588299fa09200ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac", + "reference": "388b6ced16caa751030f6a69e588299fa09200ac", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:52:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d89cc98761b8cb5a1a235a6b703ae50d34080e65", + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:24:23+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "a90ccbddffa067b51f574dea6eb25d5680839455" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/a90ccbddffa067b51f574dea6eb25d5680839455", + "reference": "a90ccbddffa067b51f574dea6eb25d5680839455", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:55:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.6", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-11-28T06:42:11+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:17:30+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "issues": "https://github.com/sebastianbergmann/resource-operations/issues", + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:45:17+00:00" + }, + { + "name": "sebastian/type", + "version": "2.3.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/81cd61ab7bbf2de744aba0ea61fae32f721df3d2", + "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/2.3.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:18:59+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "87d6f0a7436b03a57d4cf9a6a9cd0c83a355c49a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/87d6f0a7436b03a57d4cf9a6a9cd0c83a355c49a", + "reference": "87d6f0a7436b03a57d4cf9a6a9cd0c83a355c49a", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/dom-crawler": "^4.4|^5.0" + }, + "require-dev": { + "symfony/css-selector": "^4.4|^5.0", + "symfony/http-client": "^4.4|^5.0", + "symfony/mime": "^4.4|^5.0", + "symfony/process": "^4.4|^5.0" + }, + "suggest": { + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony BrowserKit Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-18T08:03:05+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "ee7cf316fb0de786cfe5ae32ee79502b290c81ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ee7cf316fb0de786cfe5ae32ee79502b290c81ea", + "reference": "ee7cf316fb0de786cfe5ae32ee79502b290c81ea", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.15" + }, + "conflict": { + "masterminds/html5": "<2.6" + }, + "require-dev": { + "masterminds/html5": "^2.6", + "symfony/css-selector": "^4.4|^5.0" + }, + "suggest": { + "symfony/css-selector": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DomCrawler Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-18T08:02:46+00:00" + }, + { + "name": "symfony/http-client", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "a77cbec69ea90dea509beef29b79748c0df33a83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/a77cbec69ea90dea509beef29b79748c0df33a83", + "reference": "a77cbec69ea90dea509beef29b79748c0df33a83", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/log": "^1.0", + "symfony/http-client-contracts": "^2.2", + "symfony/polyfill-php73": "^1.11", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.0|^2" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "1.1" + }, + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.3.1", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/http-kernel": "^4.4.13|^5.1.5", + "symfony/process": "^4.4|^5.0", + "symfony/stopwatch": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony HttpClient component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-client/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-14T10:56:50+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "41db680a15018f9c1d4b23516059633ce280ca33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41db680a15018f9c1d4b23516059633ce280ca33", + "reference": "41db680a15018f9c1d4b23516059633ce280ca33", + "shasum": "" + }, + "require": { + "php": ">=7.2.5" + }, + "suggest": { + "symfony/http-client-implementation": "" + }, + "type": "library", + "extra": { + "branch-version": "2.3", + "branch-alias": { + "dev-main": "2.3-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-14T17:08:19+00:00" + }, + { + "name": "symfony/panther", + "version": "v0.9.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/panther.git", + "reference": "dc23945f2aab40c67e45f50c1486a16dd03423f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/panther/zipball/dc23945f2aab40c67e45f50c1486a16dd03423f2", + "reference": "dc23945f2aab40c67e45f50c1486a16dd03423f2", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "php-webdriver/webdriver": "^1.8.2", + "symfony/browser-kit": "^4.4 || ^5.0", + "symfony/dom-crawler": "^4.4 || ^5.0", + "symfony/http-client": "^4.4.11 || ^5.2", + "symfony/polyfill-php72": "^1.9", + "symfony/process": "^4.4 || ^5.0" + }, + "require-dev": { + "fabpot/goutte": "^3.2.3", + "guzzlehttp/guzzle": "^6.3", + "symfony/css-selector": "^4.4 || ^5.0", + "symfony/framework-bundle": "^4.4 || ^5.0", + "symfony/mime": "^4.4 || ^5.0", + "symfony/phpunit-bridge": "^5.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Panther\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com", + "homepage": "https://dunglas.fr" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A browser testing and web scraping library for PHP and Symfony.", + "homepage": "https://dunglas.fr", + "keywords": [ + "e2e", + "scraping", + "selenium", + "symfony", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/symfony/panther/issues", + "source": "https://github.com/symfony/panther/tree/v0.9.0" + }, + "funding": [ + { + "url": "https://www.panthera.org/donate", + "type": "custom" + } + ], + "time": "2020-12-29T07:51:42+00:00" + }, + { + "name": "symfony/process", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/bd8815b8b6705298beaa384f04fabd459c10bedd", + "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.15" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-08T17:03:37+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "75a63c33a8577608444246075ea0af0d052e452a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/75a63c33a8577608444246075ea0af0d052e452a", + "reference": "75a63c33a8577608444246075ea0af0d052e452a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/master" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2020-07-12T23:59:07+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.9.1", + "source": { + "type": "git", + "url": "https://github.com/webmozart/assert.git", + "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozart/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389", + "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0 || ^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<3.9.1" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36 || ^7.5.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozart/assert/issues", + "source": "https://github.com/webmozart/assert/tree/master" + }, + "time": "2020-07-08T17:02:28+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.0.0" +} diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..dfcf75c --- /dev/null +++ b/config/cache.php @@ -0,0 +1,19 @@ + 'memory', + 'memory' => [ + 'type' => 'memory', + 'seconds' => 31536000, + ], + 'file' => [ + 'type' => 'file', + 'seconds' => 31536000, + ], + 'memcache' => [ + 'type' => 'memcache', + 'host' => '127.0.0.1', + 'port' => 11211, + 'seconds' => 31536000, + ], +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..fcdab2e --- /dev/null +++ b/config/database.php @@ -0,0 +1,17 @@ + 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'), + ], +]; diff --git a/config/email.php b/config/email.php new file mode 100644 index 0000000..710671e --- /dev/null +++ b/config/email.php @@ -0,0 +1,13 @@ + 'postmark', + 'postmark' => [ + 'type' => 'postmark', + 'token' => env('EMAIL_TOKEN'), + 'from' => [ + 'name' => env('EMAIL_FROM_NAME'), + 'email' => env('EMAIL_FROM_EMAIL'), + ], + ] +]; diff --git a/config/filesystem.php b/config/filesystem.php new file mode 100644 index 0000000..f627d00 --- /dev/null +++ b/config/filesystem.php @@ -0,0 +1,24 @@ + 'local', + 'local' => [ + 'type' => 'local', + 'path' => __DIR__ . '/../storage/app', + ], + 's3' => [ + 'type' => 's3', + 'key' => '', + 'secret' => '', + 'token' => '', + 'region' => '', + 'bucket' => '', + ], + 'ftp' => [ + 'type' => 'ftp', + 'host' => '', + 'root' => '', + 'username' => '', + 'password' => '', + ], +]; diff --git a/config/handlers.php b/config/handlers.php new file mode 100644 index 0000000..31c92b9 --- /dev/null +++ b/config/handlers.php @@ -0,0 +1,5 @@ + \App\Exceptions\Handler::class, +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..cb917db --- /dev/null +++ b/config/logging.php @@ -0,0 +1,11 @@ + 'stream', + 'stream' => [ + 'type' => 'stream', + 'path' => __DIR__ . '/../storage/app.log', + 'name' => 'App', + 'minimum' => \Monolog\Logger::DEBUG, + ], +]; diff --git a/config/providers.php b/config/providers.php new file mode 100644 index 0000000..1673c5b --- /dev/null +++ b/config/providers.php @@ -0,0 +1,17 @@ + 'database', + 'database' => [ + 'type' => 'database', + 'table' => 'jobs', + 'attempts' => 3, + ], +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..7000e27 --- /dev/null +++ b/config/session.php @@ -0,0 +1,9 @@ + 'native', + 'native' => [ + 'type' => 'native', + 'prefix' => 'framework_', + ], +]; diff --git a/database/migrations/001_CreateOrdersTable.php b/database/migrations/001_CreateOrdersTable.php new file mode 100644 index 0000000..fd30ecb --- /dev/null +++ b/database/migrations/001_CreateOrdersTable.php @@ -0,0 +1,18 @@ +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(); + } +} diff --git a/database/migrations/002_AddDeliveryInstructions.php b/database/migrations/002_AddDeliveryInstructions.php new file mode 100644 index 0000000..d908332 --- /dev/null +++ b/database/migrations/002_AddDeliveryInstructions.php @@ -0,0 +1,13 @@ +alterTable('orders'); + $table->text('delivery_instructions'); + $table->execute(); + } +} diff --git a/database/migrations/003_ChangeQuantity.php b/database/migrations/003_ChangeQuantity.php new file mode 100644 index 0000000..56fa336 --- /dev/null +++ b/database/migrations/003_ChangeQuantity.php @@ -0,0 +1,13 @@ +alterTable('orders'); + $table->int('quantity')->nullable()->alter(); + $table->execute(); + } +} diff --git a/database/migrations/004_DropPrice.php b/database/migrations/004_DropPrice.php new file mode 100644 index 0000000..7a4f8aa --- /dev/null +++ b/database/migrations/004_DropPrice.php @@ -0,0 +1,13 @@ +alterTable('orders'); + $table->dropColumn('price'); + $table->execute(); + } +} diff --git a/database/migrations/005_CreateProductsTable.php b/database/migrations/005_CreateProductsTable.php new file mode 100644 index 0000000..61e226c --- /dev/null +++ b/database/migrations/005_CreateProductsTable.php @@ -0,0 +1,15 @@ +createTable('products'); + $table->id('id'); + $table->string('name'); + $table->text('description'); + $table->execute(); + } +} diff --git a/database/migrations/006_SeedProducts.php b/database/migrations/006_SeedProducts.php new file mode 100644 index 0000000..579fc63 --- /dev/null +++ b/database/migrations/006_SeedProducts.php @@ -0,0 +1,31 @@ + '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'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); + } + } +} diff --git a/database/migrations/007_CreateUsersTable.php b/database/migrations/007_CreateUsersTable.php new file mode 100644 index 0000000..50ce7d3 --- /dev/null +++ b/database/migrations/007_CreateUsersTable.php @@ -0,0 +1,16 @@ +createTable('users'); + $table->id('id'); + $table->string('name'); + $table->string('email'); + $table->string('password'); + $table->execute(); + } +} diff --git a/database/migrations/008_CreateProfilesTable.php b/database/migrations/008_CreateProfilesTable.php new file mode 100644 index 0000000..9738ab9 --- /dev/null +++ b/database/migrations/008_CreateProfilesTable.php @@ -0,0 +1,14 @@ +createTable('profiles'); + $table->id('id'); + $table->int('user_id'); + $table->execute(); + } +} diff --git a/database/migrations/009_AddUserId.php b/database/migrations/009_AddUserId.php new file mode 100644 index 0000000..b34895d --- /dev/null +++ b/database/migrations/009_AddUserId.php @@ -0,0 +1,13 @@ +alterTable('orders'); + $table->int('user_id'); + $table->execute(); + } +} diff --git a/database/migrations/010_CreateJobsTable.php b/database/migrations/010_CreateJobsTable.php new file mode 100644 index 0000000..7fc4844 --- /dev/null +++ b/database/migrations/010_CreateJobsTable.php @@ -0,0 +1,17 @@ +createTable('jobs'); + $table->id('id'); + $table->text('closure'); + $table->text('params'); + $table->int('attempts')->default(0); + $table->bool('is_complete')->default(false); + $table->execute(); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..49ee8a8 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..162ae38 --- /dev/null +++ b/docker/nginx/default.conf @@ -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; + } +} diff --git a/docker/nginx/phpliteadmin.conf b/docker/nginx/phpliteadmin.conf new file mode 100644 index 0000000..86daa31 --- /dev/null +++ b/docker/nginx/phpliteadmin.conf @@ -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; + } +} diff --git a/docker/php/app.ini b/docker/php/app.ini new file mode 100644 index 0000000..4fe9d65 --- /dev/null +++ b/docker/php/app.ini @@ -0,0 +1,3 @@ +error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED +display_errors = On +log_errors = On diff --git a/docker/phpliteadmin/phpliteadmin.config.php b/docker/phpliteadmin/phpliteadmin.config.php new file mode 100644 index 0000000..a165a55 --- /dev/null +++ b/docker/phpliteadmin/phpliteadmin.config.php @@ -0,0 +1,5 @@ +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;" diff --git a/docs/framework.md b/docs/framework.md new file mode 100644 index 0000000..a80eb2e --- /dev/null +++ b/docs/framework.md @@ -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/.php`, picks the driver named by `'default'`, and binds it under the container alias `` (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 +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 +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 + Message::all(), + 'csrf' => csrf(), + ]); + } +} +``` + +`app/Http/Controllers/Guestbook/PostGuestbookController.php` — validates the form and saves a new message: + +```php + ['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') + +
+

Guestbook

+ + @if(session()->has('guestbook_errors')) +
    + @foreach(session('guestbook_errors') as $field => $errors) + @foreach($errors as $error) +
  1. {{ $error }}
  2. + @endforeach + @endforeach +
+ @endif + +
+ + + + +
+ +
    + @foreach($messages as $message) +
  • {{ $message->name }}: {{ $message->body }}
  • + @endforeach +
+
+``` + +`@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 `
  • ` 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/.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) +
    +

    {{ $product->name }}

    +

    {!! $product->description !!}

    +
    +@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) +
  • {{ $error }}
  • + @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..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('

    ...

    ') // 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 ``) 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. | diff --git a/framework/App.php b/framework/App.php new file mode 100644 index 0000000..b8e202e --- /dev/null +++ b/framework/App.php @@ -0,0 +1,78 @@ +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; + } +} diff --git a/framework/Cache/Driver/Driver.php b/framework/Cache/Driver/Driver.php new file mode 100644 index 0000000..2e899e3 --- /dev/null +++ b/framework/Cache/Driver/Driver.php @@ -0,0 +1,31 @@ +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; + } +} diff --git a/framework/Cache/Driver/MemcacheDriver.php b/framework/Cache/Driver/MemcacheDriver.php new file mode 100644 index 0000000..2f989d4 --- /dev/null +++ b/framework/Cache/Driver/MemcacheDriver.php @@ -0,0 +1,55 @@ +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; + } +} diff --git a/framework/Cache/Driver/MemoryDriver.php b/framework/Cache/Driver/MemoryDriver.php new file mode 100644 index 0000000..dfbe8f2 --- /dev/null +++ b/framework/Cache/Driver/MemoryDriver.php @@ -0,0 +1,54 @@ +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; + } +} diff --git a/framework/Cache/Exception/DriverException.php b/framework/Cache/Exception/DriverException.php new file mode 100644 index 0000000..cc4ffa4 --- /dev/null +++ b/framework/Cache/Exception/DriverException.php @@ -0,0 +1,9 @@ +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'); + } +} diff --git a/framework/Container.php b/framework/Container.php new file mode 100644 index 0000000..f777a62 --- /dev/null +++ b/framework/Container.php @@ -0,0 +1,80 @@ +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); + } +} diff --git a/framework/Database/Command/MigrateCommand.php b/framework/Database/Command/MigrateCommand.php new file mode 100644 index 0000000..68c1f7f --- /dev/null +++ b/framework/Database/Command/MigrateCommand.php @@ -0,0 +1,111 @@ +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(); + } +} diff --git a/framework/Database/Connection/Connection.php b/framework/Database/Connection/Connection.php new file mode 100644 index 0000000..5871cef --- /dev/null +++ b/framework/Database/Connection/Connection.php @@ -0,0 +1,45 @@ + $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(); + } +} diff --git a/framework/Database/Connection/SqliteConnection.php b/framework/Database/Connection/SqliteConnection.php new file mode 100644 index 0000000..a50cf0f --- /dev/null +++ b/framework/Database/Connection/SqliteConnection.php @@ -0,0 +1,69 @@ + $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; + } +} diff --git a/framework/Database/Exception/ConnectionException.php b/framework/Database/Exception/ConnectionException.php new file mode 100644 index 0000000..19c8189 --- /dev/null +++ b/framework/Database/Exception/ConnectionException.php @@ -0,0 +1,9 @@ +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'); + } +} diff --git a/framework/Database/Migration/Field/BoolField.php b/framework/Database/Migration/Field/BoolField.php new file mode 100644 index 0000000..c9f1f5f --- /dev/null +++ b/framework/Database/Migration/Field/BoolField.php @@ -0,0 +1,14 @@ +default = $value; + return $this; + } +} diff --git a/framework/Database/Migration/Field/DateTimeField.php b/framework/Database/Migration/Field/DateTimeField.php new file mode 100644 index 0000000..72784d5 --- /dev/null +++ b/framework/Database/Migration/Field/DateTimeField.php @@ -0,0 +1,14 @@ +default = $value; + return $this; + } +} diff --git a/framework/Database/Migration/Field/Field.php b/framework/Database/Migration/Field/Field.php new file mode 100644 index 0000000..f5bd007 --- /dev/null +++ b/framework/Database/Migration/Field/Field.php @@ -0,0 +1,27 @@ +name = $name; + } + + public function nullable(): static + { + $this->nullable = true; + return $this; + } + + public function alter(): static + { + $this->alter = true; + return $this; + } +} diff --git a/framework/Database/Migration/Field/FloatField.php b/framework/Database/Migration/Field/FloatField.php new file mode 100644 index 0000000..e1798ac --- /dev/null +++ b/framework/Database/Migration/Field/FloatField.php @@ -0,0 +1,14 @@ +default = $value; + return $this; + } +} diff --git a/framework/Database/Migration/Field/IdField.php b/framework/Database/Migration/Field/IdField.php new file mode 100644 index 0000000..aa4007e --- /dev/null +++ b/framework/Database/Migration/Field/IdField.php @@ -0,0 +1,13 @@ +default = $value; + return $this; + } +} diff --git a/framework/Database/Migration/Field/StringField.php b/framework/Database/Migration/Field/StringField.php new file mode 100644 index 0000000..e6f75be --- /dev/null +++ b/framework/Database/Migration/Field/StringField.php @@ -0,0 +1,14 @@ +default = $value; + return $this; + } +} diff --git a/framework/Database/Migration/Field/TextField.php b/framework/Database/Migration/Field/TextField.php new file mode 100644 index 0000000..e102f82 --- /dev/null +++ b/framework/Database/Migration/Field/TextField.php @@ -0,0 +1,21 @@ +default = $value; + return $this; + } +} diff --git a/framework/Database/Migration/Migration.php b/framework/Database/Migration/Migration.php new file mode 100644 index 0000000..8c1cf24 --- /dev/null +++ b/framework/Database/Migration/Migration.php @@ -0,0 +1,62 @@ +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; +} diff --git a/framework/Database/Migration/MysqlMigration.php b/framework/Database/Migration/MysqlMigration.php new file mode 100644 index 0000000..755052e --- /dev/null +++ b/framework/Database/Migration/MysqlMigration.php @@ -0,0 +1,164 @@ +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; + } +} diff --git a/framework/Database/Migration/SqliteMigration.php b/framework/Database/Migration/SqliteMigration.php new file mode 100644 index 0000000..52ba7f3 --- /dev/null +++ b/framework/Database/Migration/SqliteMigration.php @@ -0,0 +1,239 @@ +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; + } +} diff --git a/framework/Database/Model.php b/framework/Database/Model.php new file mode 100644 index 0000000..b6fde84 --- /dev/null +++ b/framework/Database/Model.php @@ -0,0 +1,189 @@ +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(); + } +} diff --git a/framework/Database/ModelCollector.php b/framework/Database/ModelCollector.php new file mode 100644 index 0000000..86dcdf2 --- /dev/null +++ b/framework/Database/ModelCollector.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/framework/Database/QueryBuilder/MysqlQueryBuilder.php b/framework/Database/QueryBuilder/MysqlQueryBuilder.php new file mode 100644 index 0000000..266855c --- /dev/null +++ b/framework/Database/QueryBuilder/MysqlQueryBuilder.php @@ -0,0 +1,15 @@ +connection = $connection; + } +} diff --git a/framework/Database/QueryBuilder/QueryBuilder.php b/framework/Database/QueryBuilder/QueryBuilder.php new file mode 100644 index 0000000..8f73db3 --- /dev/null +++ b/framework/Database/QueryBuilder/QueryBuilder.php @@ -0,0 +1,307 @@ +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()); + } +} diff --git a/framework/Database/QueryBuilder/SqliteQueryBuilder.php b/framework/Database/QueryBuilder/SqliteQueryBuilder.php new file mode 100644 index 0000000..f8d7a79 --- /dev/null +++ b/framework/Database/QueryBuilder/SqliteQueryBuilder.php @@ -0,0 +1,15 @@ +connection = $connection; + } +} diff --git a/framework/Database/Relationship.php b/framework/Database/Relationship.php new file mode 100644 index 0000000..b50df6c --- /dev/null +++ b/framework/Database/Relationship.php @@ -0,0 +1,27 @@ +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); + } +} diff --git a/framework/Database/TableName.php b/framework/Database/TableName.php new file mode 100644 index 0000000..983d173 --- /dev/null +++ b/framework/Database/TableName.php @@ -0,0 +1,14 @@ +name = $name; + } +} diff --git a/framework/Email/Driver/Driver.php b/framework/Email/Driver/Driver.php new file mode 100644 index 0000000..979201a --- /dev/null +++ b/framework/Email/Driver/Driver.php @@ -0,0 +1,12 @@ +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; + } +} diff --git a/framework/Email/Exception/CompositionException.php b/framework/Email/Exception/CompositionException.php new file mode 100644 index 0000000..7914b5a --- /dev/null +++ b/framework/Email/Exception/CompositionException.php @@ -0,0 +1,9 @@ +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'); + } +} diff --git a/framework/Filesystem/Driver/Driver.php b/framework/Filesystem/Driver/Driver.php new file mode 100644 index 0000000..9f4390f --- /dev/null +++ b/framework/Filesystem/Driver/Driver.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/framework/Filesystem/Driver/LocalDriver.php b/framework/Filesystem/Driver/LocalDriver.php new file mode 100644 index 0000000..33a2545 --- /dev/null +++ b/framework/Filesystem/Driver/LocalDriver.php @@ -0,0 +1,16 @@ +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'); + } +} diff --git a/framework/Http/Response.php b/framework/Http/Response.php new file mode 100644 index 0000000..1742f0b --- /dev/null +++ b/framework/Http/Response.php @@ -0,0 +1,104 @@ +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"); + } +} diff --git a/framework/Logging/Driver/Driver.php b/framework/Logging/Driver/Driver.php new file mode 100644 index 0000000..96ece0e --- /dev/null +++ b/framework/Logging/Driver/Driver.php @@ -0,0 +1,10 @@ +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; + } +} diff --git a/framework/Logging/Exception/DriverException.php b/framework/Logging/Exception/DriverException.php new file mode 100644 index 0000000..b24d526 --- /dev/null +++ b/framework/Logging/Exception/DriverException.php @@ -0,0 +1,9 @@ +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'); + } +} diff --git a/framework/Provider/CacheProvider.php b/framework/Provider/CacheProvider.php new file mode 100644 index 0000000..0a1a1be --- /dev/null +++ b/framework/Provider/CacheProvider.php @@ -0,0 +1,38 @@ + function($config) { + return new FileDriver($config); + }, + 'memcache' => function($config) { + return new MemcacheDriver($config); + }, + 'memory' => function($config) { + return new MemoryDriver($config); + }, + ]; + } +} diff --git a/framework/Provider/ConfigProvider.php b/framework/Provider/ConfigProvider.php new file mode 100644 index 0000000..1fc769b --- /dev/null +++ b/framework/Provider/ConfigProvider.php @@ -0,0 +1,16 @@ +bind('config', function($app) { + return new Config(); + }); + } +} diff --git a/framework/Provider/DatabaseProvider.php b/framework/Provider/DatabaseProvider.php new file mode 100644 index 0000000..4b10df7 --- /dev/null +++ b/framework/Provider/DatabaseProvider.php @@ -0,0 +1,34 @@ + function($config) { + return new SqliteConnection($config); + }, + 'mysql' => function($config) { + return new MysqlConnection($config); + }, + ]; + } +} diff --git a/framework/Provider/EmailProvider.php b/framework/Provider/EmailProvider.php new file mode 100644 index 0000000..e8448cf --- /dev/null +++ b/framework/Provider/EmailProvider.php @@ -0,0 +1,30 @@ + function($config) { + return new PostmarkDriver($config); + }, + ]; + } +} diff --git a/framework/Provider/FilesystemProvider.php b/framework/Provider/FilesystemProvider.php new file mode 100644 index 0000000..818306e --- /dev/null +++ b/framework/Provider/FilesystemProvider.php @@ -0,0 +1,30 @@ + function($config) { + return new LocalDriver($config); + }, + ]; + } +} diff --git a/framework/Provider/LoggingProvider.php b/framework/Provider/LoggingProvider.php new file mode 100644 index 0000000..e66aeff --- /dev/null +++ b/framework/Provider/LoggingProvider.php @@ -0,0 +1,30 @@ + function($config) { + return new StreamDriver($config); + }, + ]; + } +} diff --git a/framework/Provider/QueueProvider.php b/framework/Provider/QueueProvider.php new file mode 100644 index 0000000..08fbca1 --- /dev/null +++ b/framework/Provider/QueueProvider.php @@ -0,0 +1,30 @@ + function($config) { + return new DatabaseDriver($config); + }, + ]; + } +} diff --git a/framework/Provider/ResponseProvider.php b/framework/Provider/ResponseProvider.php new file mode 100644 index 0000000..130374d --- /dev/null +++ b/framework/Provider/ResponseProvider.php @@ -0,0 +1,16 @@ +bind('response', function($app) { + return new Response(); + }); + } +} diff --git a/framework/Provider/SessionProvider.php b/framework/Provider/SessionProvider.php new file mode 100644 index 0000000..8a2428b --- /dev/null +++ b/framework/Provider/SessionProvider.php @@ -0,0 +1,30 @@ + function($config) { + return new NativeDriver($config); + }, + ]; + } +} diff --git a/framework/Provider/ValidationProvider.php b/framework/Provider/ValidationProvider.php new file mode 100644 index 0000000..a543790 --- /dev/null +++ b/framework/Provider/ValidationProvider.php @@ -0,0 +1,34 @@ +bind('validator', function($app) { + $manager = new Manager(); + + $this->bindRules($app, $manager); + + return $manager; + }); + } + + private function bindRules(App $app, Manager $manager): void + { + $app->bind('validation.rule.required', fn() => new RequiredRule()); + $app->bind('validation.rule.email', fn() => new EmailRule()); + $app->bind('validation.rule.min', fn() => new MinRule()); + + $manager->addRule('required', $app->resolve('validation.rule.required')); + $manager->addRule('email', $app->resolve('validation.rule.email')); + $manager->addRule('min', $app->resolve('validation.rule.min')); + } +} diff --git a/framework/Provider/ViewProvider.php b/framework/Provider/ViewProvider.php new file mode 100644 index 0000000..231fb13 --- /dev/null +++ b/framework/Provider/ViewProvider.php @@ -0,0 +1,51 @@ +bind('view', function($app) { + $manager = new Manager(); + + $this->bindPaths($app, $manager); + $this->bindMacros($app, $manager); + $this->bindEngines($app, $manager); + + return $manager; + }); + } + + private function bindPaths(App $app, Manager $manager): void + { + $manager->addPath($app->resolve('paths.base') . '/resources/views'); + $manager->addPath($app->resolve('paths.base') . '/resources/images'); + } + + private function bindMacros(App $app, Manager $manager): void + { + $manager->addMacro('escape', fn($value) => htmlspecialchars($value, ENT_QUOTES)); + $manager->addMacro('includes', fn(...$params) => print view(...$params)); + } + + private function bindEngines(App $app, Manager $manager): void + { + $app->bind('view.engine.basic', fn() => new BasicEngine()); + $app->bind('view.engine.advanced', fn() => new AdvancedEngine()); + $app->bind('view.engine.php', fn() => new PhpEngine()); + $app->bind('view.engine.literal', fn() => new LiteralEngine()); + + $manager->addEngine('basic.php', $app->resolve('view.engine.basic')); + $manager->addEngine('advanced.php', $app->resolve('view.engine.advanced')); + $manager->addEngine('php', $app->resolve('view.engine.php')); + $manager->addEngine('svg', $app->resolve('view.engine.literal')); + } +} diff --git a/framework/Queue/Command/WorkCommand.php b/framework/Queue/Command/WorkCommand.php new file mode 100644 index 0000000..161b4cb --- /dev/null +++ b/framework/Queue/Command/WorkCommand.php @@ -0,0 +1,49 @@ +setDescription('Runs tasks that have been queued') + ->setHelp('This command waits for and runs queued jobs'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $output->writeln('Waiting for jobs.'); + + while(true) { + if ($job = app('queue')->shift()) { + try { + $job->run(); + + $output->writeln("Completed {$job->id}"); + + $job->is_complete = true; + $job->save(); + + sleep(1); + } + catch (Exception $e) { + $message = $e->getMessage(); + $output->writeln("{$message}"); + + $job->attempts = $job->attempts + 1; + $job->save(); + } + } + } + } +} diff --git a/framework/Queue/Driver/DatabaseDriver.php b/framework/Queue/Driver/DatabaseDriver.php new file mode 100644 index 0000000..3de425e --- /dev/null +++ b/framework/Queue/Driver/DatabaseDriver.php @@ -0,0 +1,32 @@ +closure = serialize($wrapper); + $job->params = serialize($params); + $job->attempts = 0; + $job->save(); + + return $job->id; + } + + public function shift(): ?Job + { + $attempts = config('queue.database.attempts'); + + return Job::where('attempts', '<', $attempts) + ->where('is_complete', false) + ->first(); + } +} diff --git a/framework/Queue/Driver/Driver.php b/framework/Queue/Driver/Driver.php new file mode 100644 index 0000000..4a9ab2b --- /dev/null +++ b/framework/Queue/Driver/Driver.php @@ -0,0 +1,12 @@ +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'); + } +} diff --git a/framework/Queue/Job.php b/framework/Queue/Job.php new file mode 100644 index 0000000..d96d5bf --- /dev/null +++ b/framework/Queue/Job.php @@ -0,0 +1,21 @@ +closure); + $params = unserialize($this->params); + + return $closure(...$params); + } +} diff --git a/framework/Routing/Exception/RouteException.php b/framework/Routing/Exception/RouteException.php new file mode 100644 index 0000000..666fcce --- /dev/null +++ b/framework/Routing/Exception/RouteException.php @@ -0,0 +1,9 @@ +method = $method; + $this->path = $path; + $this->handler = $handler; + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function parameters(): array + { + return $this->parameters; + } + + public function name(string $name = null) + { + if ($name) { + $this->name = $name; + return $this; + } + + return $this->name; + } + + public function matches(string $method, string $path): bool + { + if ( + $this->method === $method + && $this->path === $path + ) { + return true; + } + + $parameterNames = []; + + $pattern = $this->normalisePath($this->path); + + $pattern = preg_replace_callback('#{([^}]+)}/#', function (array $found) use (&$parameterNames) { + array_push($parameterNames, rtrim($found[1], '?')); + + if (str_ends_with($found[1], '?')) { + return '([^/]*)(?:/?)'; + } + + return '([^/]+)/'; + }, $pattern); + + if ( + !str_contains($pattern, '+') + && !str_contains($pattern, '*') + ) { + return false; + } + + preg_match_all("#{$pattern}#", $this->normalisePath($path), $matches); + + $parameterValues = []; + + if (count($matches[1]) > 0) { + foreach ($matches[1] as $value) { + if ($value) { + array_push($parameterValues, $value); + continue; + } + + array_push($parameterValues, null); + } + + $emptyValues = array_fill(0, count($parameterNames), false); + $parameterValues += $emptyValues; + + $this->parameters = array_combine($parameterNames, $parameterValues); + + return true; + } + + return false; + } + + private function normalisePath(string $path): string + { + $path = trim($path, '/'); + $path = "/{$path}/"; + $path = preg_replace('/[\/]{2,}/', '/', $path); + + return $path; + } + + public function dispatch() + { + if (is_array($this->handler)) { + [$class, $method] = $this->handler; + + if (is_string($class)) { + return app()->call([new $class, $method]); + } + + return app()->call([$class, $method]); + } + + return app()->call($this->handler); + } +} diff --git a/framework/Routing/Router.php b/framework/Routing/Router.php new file mode 100644 index 0000000..9ba29cc --- /dev/null +++ b/framework/Routing/Router.php @@ -0,0 +1,146 @@ +routes[] = new Route($method, $path, $handler); + return $route; + } + + public function errorHandler(int $code, callable $handler) + { + $this->errorHandlers[$code] = $handler; + } + + public function dispatch() + { + $paths = $this->paths(); + + $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $requestPath = $_SERVER['REQUEST_URI'] ?? '/'; + + $matching = $this->match($requestMethod, $requestPath); + + if ($matching) { + $this->current = $matching; + + try { + return $matching->dispatch(); + } + catch (Throwable $e) { + $result = null; + + if ($handler = config('handlers.exceptions')) { + $instance = new $handler(); + + if ($result = $instance->showThrowable($e)) { + return $result; + } + } + + return $this->dispatchError(); + } + } + + if (in_array($requestPath, $paths)) { + return $this->dispatchNotAllowed(); + } + + return $this->dispatchNotFound(); + } + + private function paths(): array + { + $paths = []; + + foreach ($this->routes as $route) { + $paths[] = $route->path(); + } + + return $paths; + } + + public function current(): ?Route + { + return $this->current; + } + + private function match(string $method, string $path): ?Route + { + foreach ($this->routes as $route) { + if ($route->matches($method, $path)) { + return $route; + } + } + + return null; + } + + public function dispatchNotAllowed() + { + $this->errorHandlers[400] ??= fn() => 'not allowed'; + return $this->respondWithStatus(400, $this->errorHandlers[400]()); + } + + public function dispatchNotFound() + { + $this->errorHandlers[404] ??= fn() => 'not found'; + return $this->respondWithStatus(404, $this->errorHandlers[404]()); + } + + public function dispatchError() + { + $this->errorHandlers[500] ??= fn() => 'server error'; + return $this->respondWithStatus(500, $this->errorHandlers[500]()); + } + + /** + * Error handlers default to returning a plain string, so if that's what + * we got, wrap it in a Response with the correct status code. A handler + * that already returned its own Response is left alone. + */ + private function respondWithStatus(int $status, mixed $result): Response + { + if ($result instanceof Response) { + return $result; + } + + return response()->status($status)->content($result); + } + + public function route(string $name, array $parameters = []): string + { + foreach ($this->routes as $route) { + if ($route->name() === $name) { + $finds = []; + $replaces = []; + + foreach ($parameters as $key => $value) { + array_push($finds, "{{$key}}"); + array_push($replaces, $value); + array_push($finds, "{{$key}?}"); + array_push($replaces, $value); + } + + $path = $route->path(); + $path = str_replace($finds, $replaces, $path); + $path = preg_replace('#{[^}]+}#', '', $path); + + return $path; + } + } + + throw new Exception('No route with that name'); + } +} diff --git a/framework/Session/Driver/Driver.php b/framework/Session/Driver/Driver.php new file mode 100644 index 0000000..3c50317 --- /dev/null +++ b/framework/Session/Driver/Driver.php @@ -0,0 +1,31 @@ +config = $config; + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + } + + public function has(string $key): bool + { + $prefix = $this->config['prefix']; + return isset($_SESSION["{$prefix}{$key}"]); + } + + public function get(string $key, mixed $default = null): mixed + { + $prefix = $this->config['prefix']; + + if (isset($_SESSION["{$prefix}{$key}"])) { + return $_SESSION["{$prefix}{$key}"]; + } + + return $default; + } + + public function put(string $key, mixed $value): static + { + $prefix = $this->config['prefix']; + $_SESSION["{$prefix}{$key}"] = $value; + return $this; + } + + public function forget(string $key): static + { + $prefix = $this->config['prefix']; + unset($_SESSION["{$prefix}{$key}"]); + return $this; + } + + public function flush(): static + { + $prefix = $this->config['prefix']; + + foreach (array_keys($_SESSION) as $key) { + if (str_starts_with($key, $prefix)) { + unset($_SESSION[$key]); + } + } + + return $this; + } +} diff --git a/framework/Session/Exception/DriverException.php b/framework/Session/Exception/DriverException.php new file mode 100644 index 0000000..97c7fce --- /dev/null +++ b/framework/Session/Exception/DriverException.php @@ -0,0 +1,9 @@ +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'); + } +} diff --git a/framework/Support/Command/ServeCommand.php b/framework/Support/Command/ServeCommand.php new file mode 100644 index 0000000..e95d91d --- /dev/null +++ b/framework/Support/Command/ServeCommand.php @@ -0,0 +1,80 @@ +setDescription('Starts a development server') + ->setHelp('You can provide an optional host and port, for the development server.') + ->addOption('host', null, InputOption::VALUE_REQUIRED) + ->addOption('port', null, InputOption::VALUE_REQUIRED); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $base = app('paths.base'); + $host = $input->getOption('host') ?: env('APP_HOST', '127.0.0.1'); + $port = $input->getOption('port') ?: env('APP_PORT', '8000'); + + if (empty($host) || empty($port)) { + throw new InvalidArgumentException('APP_HOST and APP_PORT both need values'); + } + + $this->handleSignals(); + $this->startProcess($host, $port, $base, $output); + + return Command::SUCCESS; + } + + private function command(string $host, string $port, string $base): array + { + $separator = DIRECTORY_SEPARATOR; + + return [ + PHP_BINARY, + "-S", + "{$host}:{$port}", + "{$base}{$separator}server.php", + ]; + } + + private function handleSignals(): void + { + pcntl_async_signals(true); + + pcntl_signal(SIGTERM, function($signal) { + if ($signal === SIGTERM) { + $this->process->signal(SIGKILL); + exit; + } + }); + } + + private function startProcess(string $host, string $port, string $base, OutputInterface $output): void + { + $this->process = new Process($this->command($host, $port, $base), $base); + $this->process->setTimeout(PHP_INT_MAX); + + $this->process->start(function($type, $buffer) use ($output) { + $output->write("{$buffer}"); + }); + + $output->writeln("Serving requests at http://{$host}:{$port}"); + + $this->process->wait(); + } +} diff --git a/framework/Support/Config.php b/framework/Support/Config.php new file mode 100644 index 0000000..0690656 --- /dev/null +++ b/framework/Support/Config.php @@ -0,0 +1,44 @@ +loaded[$file])) { + $base = App::getInstance()->resolve('paths.base'); + $separator = DIRECTORY_SEPARATOR; + + $this->loaded[$file] = (array) require "{$base}{$separator}config{$separator}{$file}.php"; + } + + if ($value = $this->withDots($this->loaded[$file], $segments)) { + return $value; + } + + return $default; + } + + private function withDots(array $array, array $segments): mixed + { + $current = $array; + + foreach ($segments as $segment) { + if (!isset($current[$segment])) { + return null; + } + + $current = $current[$segment]; + } + + return $current; + } +} diff --git a/framework/Support/DriverFactory.php b/framework/Support/DriverFactory.php new file mode 100644 index 0000000..d7984af --- /dev/null +++ b/framework/Support/DriverFactory.php @@ -0,0 +1,11 @@ +name(); + $factory = $this->factory(); + $drivers = $this->drivers(); + + $app->bind($name, function ($app) use ($name, $factory, $drivers) { + foreach ($drivers as $key => $value) { + $factory->addDriver($key, $value); + } + + $config = config($name); + + return $factory->connect($config[$config['default']]); + }); + } + + abstract protected function name(): string; + abstract protected function factory(): DriverFactory; + abstract protected function drivers(): array; +} diff --git a/framework/Support/ExceptionHandler.php b/framework/Support/ExceptionHandler.php new file mode 100644 index 0000000..284b159 --- /dev/null +++ b/framework/Support/ExceptionHandler.php @@ -0,0 +1,40 @@ +showValidationException($throwable); + } + + if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'dev') { + $this->showFriendlyThrowable($throwable); + } + } + + public function showValidationException(ValidationException $exception) + { + if ($session = session()) { + $session->put($exception->getSessionName(), $exception->getErrors()); + } + + return redirect(env('HTTP_REFERER')); + } + + public function showFriendlyThrowable(Throwable $throwable) + { + $whoops = new Run(); + $whoops->pushHandler(new PrettyPageHandler()); + $whoops->register(); + + throw $throwable; + } +} diff --git a/framework/Testing/ServerExtension.php b/framework/Testing/ServerExtension.php new file mode 100644 index 0000000..6032b0f --- /dev/null +++ b/framework/Testing/ServerExtension.php @@ -0,0 +1,68 @@ +serverIsRunning()) { + $this->startedServer = false; + return; + } + + $this->startedServer = true; + + $base = app('paths.base'); + $separator = DIRECTORY_SEPARATOR; + + $this->process = new Process([ + PHP_BINARY, + "{$base}{$separator}command.php", + "serve" + ], $base); + + $this->process->start(function($type, $buffer) { + print $buffer; + }); + } + + private function serverIsRunning() + { + $connection = @fsockopen( + env('APP_HOST', '127.0.0.1'), + env('APP_PORT', '8000'), + ); + + if (is_resource($connection)) { + fclose($connection); + return true; + } + + return false; + } + + private function stopServer() + { + if ($this->startedServer) { + $this->process->signal(SIGTERM); + } + } + + public function executeBeforeFirstTest(): void + { + $this->startServer(); + } + + public function executeAfterLastTest(): void + { + $this->stopServer(); + } +} diff --git a/framework/Testing/TestCase.php b/framework/Testing/TestCase.php new file mode 100644 index 0000000..3f762f8 --- /dev/null +++ b/framework/Testing/TestCase.php @@ -0,0 +1,32 @@ +fail('exception was not thrown'); + } + catch (Throwable $e) { + $actualType = $e::class; + + if ($actualType !== $exceptionType) { + $this->fail("exception was {$actualType}, but expected {$exceptionType}"); + } + + $exception = $e; + } + + return [$exception, $result]; + } +} diff --git a/framework/Testing/TestResponse.php b/framework/Testing/TestResponse.php new file mode 100644 index 0000000..04f57eb --- /dev/null +++ b/framework/Testing/TestResponse.php @@ -0,0 +1,42 @@ +response = $response; + } + + public function isRedirecting(): bool + { + return $this->response->type() === Response::REDIRECT; + } + + public function redirectingTo(): ?string + { + return $this->response->redirect(); + } + + public function follow(): static + { + while ($this->isRedirecting()) { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = $this->redirectingTo(); + $this->response = App::getInstance()->run(); + } + + return $this; + } + + public function __call(string $method, array $parameters = []): mixed + { + return $this->response->$method(...$parameters); + } +} diff --git a/framework/Validation/Exception/ValidationException.php b/framework/Validation/Exception/ValidationException.php new file mode 100644 index 0000000..911af82 --- /dev/null +++ b/framework/Validation/Exception/ValidationException.php @@ -0,0 +1,33 @@ +errors = $errors; + return $this; + } + + public function getErrors(): array + { + return $this->errors; + } + + public function setSessionName(string $sessionName): static + { + $this->sessionName = $sessionName; + return $this; + } + + public function getSessionName(): string + { + return $this->sessionName; + } +} diff --git a/framework/Validation/Manager.php b/framework/Validation/Manager.php new file mode 100644 index 0000000..d558dfc --- /dev/null +++ b/framework/Validation/Manager.php @@ -0,0 +1,57 @@ +rules[$alias] = $rule; + return $this; + } + + public function validate(array $data, array $rules, string $sessionName = 'errors'): array + { + $errors = []; + + foreach ($rules as $field => $rulesForField) { + foreach ($rulesForField as $rule) { + $name = $rule; + $params = []; + + if (str_contains($rule, ':')) { + [$name, $params] = explode(':', $rule); + $params = explode(',', $params); + } + + $processor = $this->rules[$name]; + + if (!$processor->validate($data, $field, $params)) { + if (!isset($errors[$field])) { + $errors[$field] = []; + } + + array_push($errors[$field], $processor->getMessage($data, $field, $params)); + } + } + } + + if (count($errors)) { + $exception = new ValidationException(); + $exception->setErrors($errors); + $exception->setSessionName($sessionName); + throw $exception; + } else { + if ($session = session()) { + $session->forget($sessionName); + } + } + + return array_intersect_key($data, $rules); + } +} diff --git a/framework/Validation/Rule/EmailRule.php b/framework/Validation/Rule/EmailRule.php new file mode 100644 index 0000000..2ef1ffb --- /dev/null +++ b/framework/Validation/Rule/EmailRule.php @@ -0,0 +1,20 @@ += $length; + } + + public function getMessage(array $data, string $field, array $params) + { + $length = (int) $params[0]; + + return "{$field} should be at least {$length} characters"; + } +} diff --git a/framework/Validation/Rule/RequiredRule.php b/framework/Validation/Rule/RequiredRule.php new file mode 100644 index 0000000..da564b6 --- /dev/null +++ b/framework/Validation/Rule/RequiredRule.php @@ -0,0 +1,16 @@ +path); + $folder = __DIR__ . '/../../../storage/framework/views'; + + if (!is_file("{$folder}/{$hash}.php")) { + touch("{$folder}/{$hash}.php"); + } + + $cached = realpath("{$folder}/{$hash}.php"); + + if (!file_exists($hash) || filemtime($view->path) > filemtime($hash)) { + $content = $this->compile(file_get_contents($view->path)); + file_put_contents($cached, $content); + } + + extract($view->data); + + ob_start(); + include($cached); + $contents = ob_get_contents(); + ob_end_clean(); + + if ($layout = $this->layouts[$cached] ?? null) { + $contentsWithLayout = view($layout, array_merge( + $view->data, + ['contents' => $contents], + )); + + return $contentsWithLayout; + } + + return $contents; + } + + protected function compile(string $template): string + { + // replace `@extends` with `$this->extends` + $template = preg_replace_callback('#@extends\(((?<=\().*(?=\)))\)#', function($matches) { + return 'extends(' . $matches[1] . '); ?>'; + }, $template); + + // replace `@if` with `if(...):` + $template = preg_replace_callback('#@if\(((?<=\().*(?=\)))\)#', function($matches) { + return ''; + }, $template); + + // replace `@endif` with `endif;` + $template = preg_replace_callback('#@endif#', function($matches) { + return ''; + }, $template); + + // replace `@foreach` with `foreach(...):` + $template = preg_replace_callback('#@foreach\(((?<=\().*(?=\)))\)#', function($matches) { + return ''; + }, $template); + + // replace `@endforeach` with `endforeach;` + $template = preg_replace_callback('#@endforeach#', function($matches) { + return ''; + }, $template); + + // replace `@[anything](...)` with `$this->[anything](...)` + $template = preg_replace_callback('#\s+@([^(]+)\(((?<=\().*(?=\)))\)#', function($matches) { + return '' . $matches[1] . '(' . $matches[2] . '); ?>'; + }, $template); + + // replace `{{ ... }}` with `print $this->escape(...)` + $template = preg_replace_callback('#\{\{([^}]*)\}\}#', function($matches) { + return 'escape(' . $matches[1] . '); ?>'; + }, $template); + + // replace `{!! ... !!}` with `print ...` + $template = preg_replace_callback('#\{!!([^}]+)!!\}#', function($matches) { + return ''; + }, $template); + + return $template; + } + + protected function extends(string $template): static + { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + $this->layouts[realpath($backtrace[0]['file'])] = $template; + return $this; + } + + public function __call(string $name, $values) + { + return $this->manager->useMacro($name, ...$values); + } +} diff --git a/framework/View/Engine/BasicEngine.php b/framework/View/Engine/BasicEngine.php new file mode 100644 index 0000000..6f1a3b4 --- /dev/null +++ b/framework/View/Engine/BasicEngine.php @@ -0,0 +1,24 @@ +path); + + foreach ($view->data as $key => $value) { + $contents = str_replace( + '{'.$key.'}', $value, $contents + ); + } + + return $contents; + } +} diff --git a/framework/View/Engine/Engine.php b/framework/View/Engine/Engine.php new file mode 100644 index 0000000..bd64c6e --- /dev/null +++ b/framework/View/Engine/Engine.php @@ -0,0 +1,12 @@ +manager = $manager; + return $this; + } +} diff --git a/framework/View/Engine/LiteralEngine.php b/framework/View/Engine/LiteralEngine.php new file mode 100644 index 0000000..5e2f8eb --- /dev/null +++ b/framework/View/Engine/LiteralEngine.php @@ -0,0 +1,16 @@ +path); + } +} diff --git a/framework/View/Engine/PhpEngine.php b/framework/View/Engine/PhpEngine.php new file mode 100644 index 0000000..fdd619f --- /dev/null +++ b/framework/View/Engine/PhpEngine.php @@ -0,0 +1,47 @@ +data); + + ob_start(); + include($view->path); + $contents = ob_get_contents(); + ob_end_clean(); + + if ($layout = $this->layouts[$view->path] ?? null) { + $contentsWithLayout = view($layout, array_merge( + $view->data, + ['contents' => $contents], + )); + + return $contentsWithLayout; + } + + return $contents; + } + + protected function extends(string $template): static + { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + $this->layouts[realpath($backtrace[0]['file'])] = $template; + return $this; + } + + public function __call(string $name, $values) + { + return $this->manager->useMacro($name, ...$values); + } +} diff --git a/framework/View/Manager.php b/framework/View/Manager.php new file mode 100644 index 0000000..c7286ff --- /dev/null +++ b/framework/View/Manager.php @@ -0,0 +1,59 @@ +paths, $path); + return $this; + } + + public function addEngine(string $extension, Engine $engine): static + { + $this->engines[$extension] = $engine; + $this->engines[$extension]->setManager($this); + return $this; + } + + public function render(string $template, array $data = []): View + { + foreach ($this->engines as $extension => $engine) { + foreach ($this->paths as $path) { + $file = "{$path}/{$template}.{$extension}"; + + if (is_file($file)) { + return new View($engine, realpath($file), $data); + } + } + } + + throw new Exception("Could not resolve '{$template}'"); + } + + public function addMacro(string $name, Closure $closure): static + { + $this->macros[$name] = $closure; + return $this; + } + + public function useMacro(string $name, ...$values) + { + if (isset($this->macros[$name])) { + $bound = $this->macros[$name]->bindTo($this); + return $bound(...$values); + } + + throw new Exception("Macro isn't defined: '{$name}'"); + } +} diff --git a/framework/View/View.php b/framework/View/View.php new file mode 100644 index 0000000..eda9d28 --- /dev/null +++ b/framework/View/View.php @@ -0,0 +1,19 @@ +engine->render($this); + } +} diff --git a/framework/helpers.php b/framework/helpers.php new file mode 100644 index 0000000..2e52d5d --- /dev/null +++ b/framework/helpers.php @@ -0,0 +1,132 @@ +resolve($alias); + } +} + +if (!function_exists('view')) { + function view(string $template, array $data = []): View\View + { + return app('view')->render($template, $data); + } +} + +if (!function_exists('validate')) { + function validate(array $data, array $rules, string $sessionName = 'errors') + { + return app('validator')->validate($data, $rules, $sessionName); + } +} + +if (!function_exists('response')) { + function response() + { + return app('response'); + } +} + +if (!function_exists('redirect')) { + function redirect(string $url) + { + return response()->redirect($url); + } +} + +if (!function_exists('csrf')) { + function csrf() + { + $session = session(); + + if (!$session) { + throw new Exception('Session is not enabled'); + } + + $session->put('token', $token = bin2hex(random_bytes(32))); + + return $token; + } +} + +if (!function_exists('secure')) { + function secure() + { + $session = session(); + + if (!$session) { + throw new Exception('Session is not enabled'); + } + + if (!isset($_POST['csrf']) || !$session->has('token') || !hash_equals($session->get('token'), $_POST['csrf'])) { + throw new Exception('CSRF token mismatch'); + } + } +} + +if (!function_exists('dd')) { + function dd(...$params) + { + var_dump(...$params); + die; + } +} + +if (!function_exists('basePath')) { + function basePath(string $newBasePath = null): ?string + { + return app('paths.base'); + } +} + +if (!function_exists('env')) { + function env(string $key, mixed $default = null): mixed + { + if (isset($_SERVER[$key])) { + return $_SERVER[$key]; + } + + if (isset($_ENV[$key])) { + return $_ENV[$key]; + } + + $value = getenv($key); + + if ($value !== false) { + return $value; + } + + return $default; + } +} + +if (!function_exists('config')) { + function config(string $key = null, mixed $default = null): mixed + { + if (is_null($key)) { + return app('config'); + } + + return app('config')->get($key, $default); + } +} + +if (!function_exists('session')) { + function session(string $key = null, mixed $default = null): mixed + { + if (is_null($key)) { + return app('session'); + } + + return app('session')->get($key, $default); + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e922b09 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,14 @@ + + + + tests + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..f6e70b0 --- /dev/null +++ b/public/index.php @@ -0,0 +1,7 @@ +bind('paths.base', fn() => __DIR__ . '/..'); +$app->prepare()->run()->send(); diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..865f03e --- /dev/null +++ b/readme.md @@ -0,0 +1,41 @@ +# Whoosh website + +To run the local development server, try: + +``` +export PHP_ENV=prod && php -S 127.0.0.1:8000 -t . +``` + +To make requests with cURL, try: + +``` +curl -X DELETE http://127.0.0.1:8000/old-home +``` + +## Running with Docker + +``` +docker compose up -d --build +``` + +This starts two services: + +- **app** — nginx + php-fpm, served at http://localhost:8080 +- **db** — MySQL 8, exposed on host port `33060` (only used if `DB_CONNECTION=mysql`) + +The default database connection is **SQLite** (`database/database.sqlite`), configured via `DB_CONNECTION` in `.env` / `docker-compose.yml`. On first boot the `app` container automatically creates the SQLite file with the correct permissions and runs migrations. To switch to MySQL instead, set `DB_CONNECTION=mysql` in `docker-compose.yml`. + +To run migrations manually (e.g. after adding a new migration file): + +``` +docker compose exec app php command.php migrate +docker compose exec app php command.php migrate --fresh # drops all tables first, for a clean slate +``` + +### phpLiteAdmin + +A SQLite admin UI is available at http://localhost:8081, password `admin` (change it via the `PHPLITEADMIN_PASSWORD` environment variable in `docker-compose.yml`). It's a development-only tool — don't expose port 8081 outside your local machine. + +## Framework guide + +This app is built on a small custom MVC framework in `framework/`. See [docs/framework.md](docs/framework.md) for how routing, controllers, views, models/migrations, validation, sessions, caching, queues, and the CLI commands all work. diff --git a/resources/images/rocket.svg b/resources/images/rocket.svg new file mode 100644 index 0000000..1704518 --- /dev/null +++ b/resources/images/rocket.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + Outer space + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/views/home.advanced.php b/resources/views/home.advanced.php new file mode 100644 index 0000000..8132d5a --- /dev/null +++ b/resources/views/home.advanced.php @@ -0,0 +1,22 @@ +@extends('layout') +@includes('includes/large-feature') +@foreach($products as $i => $product) +
    +
    +

    + {{ $product->name }} +

    +

    + {!! $product->description !!} +

    + + Order + +
    +
    +@endforeach diff --git a/resources/views/includes/large-feature.advanced.php b/resources/views/includes/large-feature.advanced.php new file mode 100644 index 0000000..b10c2fc --- /dev/null +++ b/resources/views/includes/large-feature.advanced.php @@ -0,0 +1,24 @@ +
    +
    +
    + @includes('rocket') +
    +
    + + Whoosh! + +
    + A place to buy rocket things +
    +
      +
    1. Home
    2. + @if(session()->has('user_id')) +
    3. Log out
    4. + @endif + @if(!session()->has('user_id')) +
    5. Register
    6. + @endif +
    +
    +
    +
    diff --git a/resources/views/includes/small-feature.advanced.php b/resources/views/includes/small-feature.advanced.php new file mode 100644 index 0000000..fded808 --- /dev/null +++ b/resources/views/includes/small-feature.advanced.php @@ -0,0 +1,21 @@ +
    +
    +
    + @includes('rocket') +
    +
    + + Whoosh! + +
      +
    1. Home
    2. + @if(session()->has('user_id')) +
    3. Log out
    4. + @endif + @if(!session()->has('user_id')) +
    5. Register
    6. + @endif +
    +
    +
    +
    diff --git a/resources/views/layout.advanced.php b/resources/views/layout.advanced.php new file mode 100644 index 0000000..14887c6 --- /dev/null +++ b/resources/views/layout.advanced.php @@ -0,0 +1,20 @@ + + + + Whoosh! + + + + + + + +
    + {!! $contents !!} +
    + + diff --git a/resources/views/products/view.advanced.php b/resources/views/products/view.advanced.php new file mode 100644 index 0000000..fd7dc94 --- /dev/null +++ b/resources/views/products/view.advanced.php @@ -0,0 +1,45 @@ +@extends('layout') +@includes('includes/large-feature') +
    +

    + {{ $product->name }} +

    +

    + {!! $product->description !!} +

    +

    + Order +

    +
    + @if(session()->has('errors')) +
      + @foreach(session('errors') as $field => $errors) + @foreach($errors as $error) +
    1. {{ $error }}
    2. + @endforeach + @endforeach +
    + @endif + + + +
    +
    diff --git a/resources/views/users/register.advanced.php b/resources/views/users/register.advanced.php new file mode 100644 index 0000000..e345c45 --- /dev/null +++ b/resources/views/users/register.advanced.php @@ -0,0 +1,106 @@ +@extends('layout') +@includes('includes/small-feature') +
    +
    +

    + Register +

    +
    + @if(session()->has('register_errors')) +
      + @foreach(session()->get('register_errors') as $field => $errors) + @foreach($errors as $error) +
    1. {{ $error }}
    2. + @endforeach + @endforeach +
    + @endif + + + + + +
    +
    +
    +

    + Log in +

    + +
    +
    diff --git a/server.php b/server.php new file mode 100644 index 0000000..de53b13 --- /dev/null +++ b/server.php @@ -0,0 +1,11 @@ +request('GET', 'http://127.0.0.1:8000/register'); + + $client->waitFor('.log-in-button'); + $client->executeScript("document.querySelector('.log-in-button').click()"); + + $client->waitFor('.log-in-errors'); + $element = $client->findElement(WebDriverBy::className('log-in-form')); + + $this->assertStringContainsString('email is required', $element->getText()); + $this->assertStringContainsString('password is required', $element->getText()); + } + + public function testRegisterForm() + { + $client = Client::createFirefoxClient(); + $client->request('GET', 'http://127.0.0.1:8000/register'); + + $client->waitFor('.register-button'); + $client->executeScript("document.querySelector('.register-button').click()"); + + $client->waitFor('.register-errors'); + $element = $client->findElement(WebDriverBy::className('register-form')); + + $this->assertStringContainsString('name is required', $element->getText()); + $this->assertStringContainsString('email is required', $element->getText()); + $this->assertStringContainsString('password is required', $element->getText()); + } +} diff --git a/tests/RoutingTest.php b/tests/RoutingTest.php new file mode 100644 index 0000000..c9e417a --- /dev/null +++ b/tests/RoutingTest.php @@ -0,0 +1,32 @@ +assertStringContainsString($expected, 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($response->redirectingTo(), '/register'); + } +} diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php new file mode 100644 index 0000000..28e0f3f --- /dev/null +++ b/tests/ValidationTest.php @@ -0,0 +1,80 @@ +manager = new Manager(); + $this->manager->addRule('email', new EmailRule()); + $this->manager->addRule('min', new MinRule()); + $this->manager->addRule('required', new RequiredRule()); + } + + public function testInvalidEmailValuesFail() + { + $expected = ['email' => ['email should be an email']]; + + [ $exception ] = $this->assertExceptionThrown( + fn() => $this->manager->validate(['email' => 'foo'], ['email' => ['email']]), + ValidationException::class, + ); + + $this->assertEquals($expected, $exception->getErrors()); + } + + public function testValidEmailValuesPass() + { + $data = $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['email']]); + $this->assertEquals($data['email'], 'foo@bar.com'); + } + + public function testInvalidRequiredValuesFail() + { + $expected = ['email' => ['email is required']]; + + [ $exception ] = $this->assertExceptionThrown( + fn() => $this->manager->validate(['email' => ''], ['email' => ['required']]), + ValidationException::class, + ); + + $this->assertEquals($expected, $exception->getErrors()); + } + + public function testValidRequiredValuesPass() + { + $data = $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['required']]); + $this->assertEquals($data['email'], 'foo@bar.com'); + } + + public function testInvalidMinValuesFail() + { + $expected = ['email' => ['email should be at least 4 characters']]; + + [ $exception ] = $this->assertExceptionThrown( + fn() => $this->manager->validate(['email' => 'foo'], ['email' => ['min:4']]), + ValidationException::class, + ); + + $this->assertEquals($expected, $exception->getErrors()); + } + + public function testValidMinValuesPass() + { + $data = $this->manager->validate(['email' => 'foo@bar.com'], ['email' => ['min:4']]); + $this->assertEquals($data['email'], 'foo@bar.com'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b290fd8 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,7 @@ +bind('paths.base', fn() => __DIR__ . '/..'); +$app->prepare();