From e961f38c36c3e7d27e76396300b7a87b49c0dc8c Mon Sep 17 00:00:00 2001 From: Sharif <54396379+developersharif@users.noreply.github.com> Date: Thu, 17 Jul 2025 13:40:38 +0600 Subject: [PATCH 1/2] Update .env --- .env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 257a014..c96810e 100755 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -APP_NAME=APP_NAME +APP_NAME=APP_NAME. APP_DEBUG=true APP_URL=http://localhost:4000 -TIME_ZONE=Asia/Dhaka \ No newline at end of file +TIME_ZONE=Asia/Dhaka From b4379491ccfc10ff150e05abb7c30d6629a9f3e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 07:31:19 +0000 Subject: [PATCH 2/2] refactor: production-grade PHP 8.2+ modernization - Router: registry+dispatch pattern, named routes, groups, method spoofing, redirect/view helpers, proper 404 handling - HTTP: new Request (immutable, validation, headers, bearer token) and Response (json, redirect, abort, download, security headers) classes - Exceptions: HttpException hierarchy (404/403/422) for clean error flow - Database: singleton pattern, readonly properties, clean DSN building, exceptions propagate to global handler (no exit() in drivers) - Controller: abstract base with json/redirect/abort/request helpers - global.php: strict types, hash_equals CSRF, config caching, new helpers (redirect, abort, json_response, url, e, flash, old, asset now returns string) - bootstrap.php: structured prod error handler with file logging, security headers, proper session guard, timezone validation - CLI: make:controller + make:middleware scaffolding, port validation, passthru instead of system - composer.json: php>=8.2 constraint, clean autoload, sorted packages - Views: modernized welcome/404/500 pages - Docs: routing, controllers, database, middleware, helpers, cli, configuration, request-response guides + full README rewrite https://claude.ai/code/session_01K6298JMbeVUHHhS42n45Fm --- .env | 15 +- .env.example | 15 + .gitignore | 10 +- app/web/controller/WelcomeController.php | 10 +- app/web/middleware/Middleware.php | 25 +- boot/bootstrap.php | 90 +++- composer.json | 59 ++- config/app.php | 13 +- config/database.php | 36 +- database/.gitkeep | 0 docs/cli.md | 104 ++++ docs/configuration.md | 123 +++++ docs/controllers.md | 160 +++++++ docs/database.md | 233 +++++++++ docs/helpers.md | 212 +++++++++ docs/middleware.md | 132 +++++ docs/request-response.md | 162 +++++++ docs/routing.md | 180 +++++++ readme.md | 555 +++++++--------------- resources/views/404.php | 35 +- resources/views/500.php | 34 ++ resources/views/welcome.php | 64 ++- routes/web.php | 31 +- storage/logs/.gitkeep | 0 system/console/boot.php | 186 ++++++-- system/controller/Controller.php | 57 ++- system/database/Database.php | 59 ++- system/database/drivers/mysql/Mysql.php | 64 ++- system/database/drivers/sqlite/Driver.php | 65 ++- system/exception/ForbiddenException.php | 13 + system/exception/HttpException.php | 47 ++ system/exception/NotFoundException.php | 13 + system/exception/ValidationException.php | 29 ++ system/helper/global.php | 301 ++++++++++-- system/http/Request.php | 292 ++++++++++++ system/http/Response.php | 126 +++++ system/router/Route.php | 348 +++++++++----- 37 files changed, 3186 insertions(+), 712 deletions(-) create mode 100644 .env.example create mode 100644 database/.gitkeep create mode 100644 docs/cli.md create mode 100644 docs/configuration.md create mode 100644 docs/controllers.md create mode 100644 docs/database.md create mode 100644 docs/helpers.md create mode 100644 docs/middleware.md create mode 100644 docs/request-response.md create mode 100644 docs/routing.md create mode 100644 resources/views/500.php create mode 100644 storage/logs/.gitkeep create mode 100644 system/exception/ForbiddenException.php create mode 100644 system/exception/HttpException.php create mode 100644 system/exception/NotFoundException.php create mode 100644 system/exception/ValidationException.php create mode 100644 system/http/Request.php create mode 100644 system/http/Response.php diff --git a/.env b/.env index c96810e..4f4a70d 100755 --- a/.env +++ b/.env @@ -1,4 +1,15 @@ -APP_NAME=APP_NAME. +APP_NAME="Micro App" +APP_ENV=local APP_DEBUG=true APP_URL=http://localhost:4000 -TIME_ZONE=Asia/Dhaka +APP_LOCALE=en + +TIME_ZONE=UTC + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE= +DB_USERNAME=root +DB_PASSWORD= +DB_SOCKET= diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2618c19 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +APP_NAME="Micro App" +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:4000 +APP_LOCALE=en + +TIME_ZONE=UTC + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=your_database +DB_USERNAME=root +DB_PASSWORD= +DB_SOCKET= diff --git a/.gitignore b/.gitignore index 2ea5707..fcee694 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -/vendor +/vendor/ .env .phpunit.result.cache -/.vscode -.composer.lock \ No newline at end of file +/.vscode/ +/.idea/ +composer.lock +/storage/logs/*.log +/database/*.sqlite +*.DS_Store diff --git a/app/web/controller/WelcomeController.php b/app/web/controller/WelcomeController.php index e97c173..aaca1bd 100755 --- a/app/web/controller/WelcomeController.php +++ b/app/web/controller/WelcomeController.php @@ -1,15 +1,15 @@ view('welcome'); } -} \ No newline at end of file +} diff --git a/app/web/middleware/Middleware.php b/app/web/middleware/Middleware.php index 271baf8..1275c75 100755 --- a/app/web/middleware/Middleware.php +++ b/app/web/middleware/Middleware.php @@ -1,11 +1,30 @@ safeLoad(); -if ($_ENV["APP_DEBUG"] === 'true') { - $whoops = new \Whoops\Run; - $whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler); - $whoops->register(); + +// ─── Error / exception handling ────────────────────────────────────────────── +if (env('APP_DEBUG') === true) { + // Pretty error pages in development (requires filp/whoops in require-dev) + if (class_exists(\Whoops\Run::class)) { + $whoops = new \Whoops\Run(); + $whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler()); + $whoops->register(); + } else { + ini_set('display_errors', '1'); + ini_set('display_startup_errors', '1'); + error_reporting(E_ALL); + } } else { - ini_set('display_errors', 0); + // Production: suppress display, log to file, show friendly error pages + ini_set('display_errors', '0'); + ini_set('log_errors', '1'); + + $logDir = APP_ROOT . 'storage/logs/'; + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + ini_set('error_log', $logDir . 'error.log'); + + set_exception_handler(static function (\Throwable $e) use ($logDir): void { + $status = $e instanceof \system\exception\HttpException + ? $e->getStatusCode() + : 500; + + // Log everything except expected HTTP errors below 500 + if ($status >= 500) { + error_log(sprintf( + '[%s] %s in %s:%d', + date('Y-m-d H:i:s'), + $e->getMessage(), + $e->getFile(), + $e->getLine(), + ), 3, $logDir . 'error.log'); + } + + http_response_code($status); + + $viewFile = defined('VIEWS_PATH') ? VIEWS_PATH . "{$status}.php" : null; + + if ($viewFile !== null && file_exists($viewFile)) { + include $viewFile; + } else { + echo "

{$status} Error

"; + } + + exit(); + }); +} + +// ─── Timezone ──────────────────────────────────────────────────────────────── +$timezone = (string) env('TIME_ZONE', 'UTC'); + +if (!date_default_timezone_set($timezone)) { + date_default_timezone_set('UTC'); } -date_default_timezone_set($_ENV['TIME_ZONE']); + +// ─── Security headers ──────────────────────────────────────────────────────── +\system\http\Response::withSecurityHeaders(); + +// ─── Routes ────────────────────────────────────────────────────────────────── require_once APP_ROOT . 'routes/web.php'; -\system\router\Route::any('/404', '404'); \ No newline at end of file + +// ─── Dispatch ──────────────────────────────────────────────────────────────── +\system\router\Route::dispatch(); diff --git a/composer.json b/composer.json index 8bd160b..866afa0 100755 --- a/composer.json +++ b/composer.json @@ -1,22 +1,8 @@ { "name": "grayphp/micro", - "description": "PHP micro Framework.", + "description": "A lightweight, production-grade PHP micro framework.", + "keywords": ["php", "framework", "micro", "router", "mvc"], "type": "project", - "autoload": { - "files": [ - "system/helper/global.php" - ], - "psr-4": { - "system\\": "system/", - "config\\": "config/", - "app\\": "app/", - "helper\\": "app/helper/" - } - }, - "require-dev": { - "filp/whoops": "^2.14", - "phpunit/phpunit": "^9.5.8" - }, "license": "MIT", "authors": [ { @@ -24,18 +10,41 @@ "email": "developersharif@yahoo.com" } ], - "config": { - "optimize-autoloader": true, - "prepend-autoloader": false, - "platform-check": false - }, "require": { - "simple-crud/simple-crud": "^7.5", + "php": ">=8.2", "ext-mbstring": "*", "ext-openssl": "*", + "ext-pdo": "*", + "simple-crud/simple-crud": "^7.5", "vlucas/phpdotenv": "^5.5", "phpmailer/phpmailer": "^6.6", - "nesbot/carbon": "^2.62.1", - "psr/simple-cache": "^1.0|^2.0|^3.0" - } + "nesbot/carbon": "^3.0|^2.72", + "psr/simple-cache": "^3.0" + }, + "require-dev": { + "filp/whoops": "^2.15", + "phpunit/phpunit": "^11.0" + }, + "autoload": { + "files": [ + "system/helper/global.php" + ], + "psr-4": { + "system\\": "system/", + "config\\": "config/", + "app\\": "app/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/config/app.php b/config/app.php index 82ece51..8401683 100755 --- a/config/app.php +++ b/config/app.php @@ -1,8 +1,13 @@ env('APP_NAME', 'Micro Framework'), -]; \ No newline at end of file + 'name' => env('APP_NAME', 'Micro Framework'), + 'env' => env('APP_ENV', 'production'), + 'debug' => env('APP_DEBUG', false), + 'url' => env('APP_URL', 'http://localhost'), + 'timezone' => env('TIME_ZONE', 'UTC'), + 'locale' => env('APP_LOCALE', 'en'), + 'charset' => 'UTF-8', +]; diff --git a/config/database.php b/config/database.php index 2fd3e82..c77e3c4 100755 --- a/config/database.php +++ b/config/database.php @@ -1,25 +1,35 @@ env('DB_CONNECTION', 'mysql'), // default connection mysql + + /* + |-------------------------------------------------------------------------- + | Default database connection + |-------------------------------------------------------------------------- + | Supported values: "mysql" | "sqlite" + */ + 'default' => env('DB_CONNECTION', 'mysql'), + 'connections' => [ 'sqlite' => [ - 'driver' => 'sqlite', - 'database' => env('DB_DATABASE', DATABASE_PATH . 'database.sqlite') + 'driver' => 'sqlite', + 'database' => env('DB_DATABASE', DATABASE_PATH . 'database.sqlite'), ], + 'mysql' => [ - 'driver' => 'mysql', - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', ''), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), + 'driver' => 'mysql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', ''), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8mb4' + 'charset' => 'utf8mb4', ], - ] -]; \ No newline at end of file + ], + +]; diff --git a/database/.gitkeep b/database/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..7f3b43a --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,104 @@ +# CLI + +Micro includes a simple command-line tool at the project root. + +``` +php dev [arguments] +``` + +--- + +## Commands + +### `serve` / `start` + +Start the built-in PHP development server on port **4000** (default): + +```bash +php dev serve +php dev start +``` + +Use a custom port: + +```bash +php dev serve 8080 +php dev start 3000 +``` + +> **Note:** The development server is not suitable for production. Use Nginx or Apache behind PHP-FPM in production environments. + +--- + +### `make:controller` + +Scaffold a new controller class in `app/web/controller/`: + +```bash +php dev make:controller PostController +``` + +Generated file (`app/web/controller/PostController.php`): + +```php +view('welcome'); + } +} +``` + +The short alias `-c` is also accepted: + +```bash +php dev -c UserController +``` + +--- + +### `make:middleware` + +Scaffold a new middleware class in `app/web/middleware/`: + +```bash +php dev make:middleware AuthMiddleware +``` + +Generated file (`app/web/middleware/AuthMiddleware.php`): + +```php + env('MAIL_DRIVER', 'smtp'), + 'host' => env('MAIL_HOST', 'smtp.mailtrap.io'), + 'port' => env('MAIL_PORT', 2525), + 'from' => env('MAIL_FROM', 'noreply@example.com'), +]; +``` + +Then read it anywhere: + +```php +$host = config('mail', 'host'); +``` + +--- + +## Debug Mode + +When `APP_DEBUG=true`: +- [Whoops](https://github.com/filp/whoops) pretty error pages are displayed +- Stack traces expose file paths and source code + +When `APP_DEBUG=false`: +- Errors are logged to `storage/logs/error.log` +- Users see the `resources/views/500.php` template +- No sensitive information is leaked + +> **Always set `APP_DEBUG=false` in production.** + +--- + +## Security Headers + +The bootstrap automatically adds these headers on every response: + +| Header | Value | +|--------|-------| +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `SAMEORIGIN` | +| `X-XSS-Protection` | `1; mode=block` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | + +Add additional headers in `boot/bootstrap.php` or inside a global middleware. diff --git a/docs/controllers.md b/docs/controllers.md new file mode 100644 index 0000000..4ee47ab --- /dev/null +++ b/docs/controllers.md @@ -0,0 +1,160 @@ +# Controllers + +Controllers live in `app/web/controller/` and must extend `system\controller\Controller`. + +--- + +## Generating a Controller + +```bash +php dev make:controller PostController +``` + +This creates `app/web/controller/PostController.php`: + +```php +view('welcome'); + } +} +``` + +--- + +## Rendering Views + +```php +public function show(string $id): void +{ + $post = DB()->post[(int) $id]; + + if ($post === null) { + $this->abort(404); + } + + $this->view('posts.show', ['post' => $post]); +} +``` + +--- + +## JSON Responses + +```php +public function index(): void +{ + $users = DB()->user->select()->get(); + + $this->json(['data' => $users]); +} + +public function store(): void +{ + $errors = $this->request()->validate([ + 'name' => 'required|min:2|max:100', + 'email' => 'required|email', + ]); + + if ($errors) { + $this->json(['errors' => $errors], 422); + } + + // ... create user + $this->json(['message' => 'Created'], 201); +} +``` + +--- + +## Redirects + +```php +public function store(): void +{ + // ... save data + $this->redirect('/posts'); +} + +public function update(string $id): void +{ + // ... update + $this->redirect(url('post.show', ['id' => $id])); +} +``` + +--- + +## Aborting with HTTP Errors + +```php +public function show(string $id): void +{ + $post = DB()->post[(int) $id]; + + if ($post === null) { + $this->abort(404); + } + + // ... +} + +public function edit(string $id): void +{ + if (!$this->isAdmin()) { + $this->abort(403, 'Admins only.'); + } + // ... +} +``` + +--- + +## Accessing the Request + +```php +public function store(): void +{ + $req = $this->request(); + + $name = $req->input('name'); + $email = $req->input('email'); + $ip = $req->ip; + + // Validate + $errors = $req->validate([ + 'name' => 'required|max:100', + 'email' => 'required|email', + ]); + + if ($errors) { + $this->json(['errors' => $errors], 422); + } + + // ... +} +``` + +See [Request & Response](request-response.md) for the full API. + +--- + +## Base Controller Methods + +| Method | Description | +|--------|-------------| +| `$this->view(string $template, array $data = [])` | Render a view template | +| `$this->json(mixed $data, int $status = 200)` | Send JSON and exit | +| `$this->redirect(string $url, int $status = 302)` | Redirect and exit | +| `$this->abort(int $status, string $message = '')` | HTTP error response and exit | +| `$this->request()` | Return the current `Request` instance | diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..e425dbb --- /dev/null +++ b/docs/database.md @@ -0,0 +1,233 @@ +# Database + +Micro uses [SimpleCrud](https://github.com/oscarotero/simple-crud) as its database ORM, with PDO under the hood. + +--- + +## Configuration + +Edit `config/database.php` and set your credentials in `.env`: + +```ini +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=my_app +DB_USERNAME=root +DB_PASSWORD=secret +``` + +For SQLite: + +```ini +DB_CONNECTION=sqlite +DB_DATABASE=/absolute/path/to/database.sqlite +``` + +--- + +## Accessing the Database + +```php +$db = DB(); // SimpleCrud Database instance (singleton) +$pdo = SQL(); // Raw PDO connection (singleton) +``` + +--- + +## Basic CRUD + +### Read + +```php +// By primary key +$post = DB()->post[5]; + +// Check existence +if (isset(DB()->post[5])) { ... } + +// Count rows +$total = count(DB()->post); +``` + +### Create + +```php +// Insert +DB()->post[] = ['title' => 'Hello World', 'body' => '...']; + +// Insert and get new row +$post = DB()->post->create(['title' => 'Hello'])->save(); +``` + +### Update + +```php +// Update by id +DB()->post[5] = ['title' => 'Updated Title']; + +// Update via row object +$post = DB()->post[5]; +$post->title = 'New Title'; +$post->save(); +``` + +### Delete + +```php +// Delete by id +unset(DB()->post[5]); + +// Delete via row object +$post = DB()->post[5]; +$post->delete(); +``` + +--- + +## Queries + +```php +// Select with conditions +$posts = DB()->post + ->select() + ->where('status = ', 'published') + ->orderBy('created_at DESC') + ->limit(10) + ->get(); + +foreach ($posts as $post) { + echo $post->title; +} + +// Select first matching row +$post = DB()->post + ->select() + ->one() + ->where('slug = ', 'my-post') + ->get(); + +// Select by field +$user = DB()->user->get(['email' => 'user@example.com']); + +// Select or create +$tag = DB()->tag->getOrCreate(['slug' => 'php']); +``` + +### Aggregates + +```php +$count = DB()->post->selectAggregate('COUNT')->get(); +$sum = DB()->post->selectAggregate('SUM', 'views')->get(); +``` + +### Update Query + +```php +DB()->post + ->update(['status' => 'archived']) + ->where('created_at < ', '2023-01-01') + ->get(); +``` + +### Delete Query + +```php +DB()->post + ->delete() + ->where('id = ', 42) + ->get(); +``` + +### Insert Query + +```php +$id = DB()->post + ->insert(['title' => 'Hello', 'body' => 'World']) + ->get(); +``` + +--- + +## Pagination + +```php +$query = DB()->post + ->select() + ->where('status = ', 'published') + ->orderBy('created_at DESC') + ->page(1) + ->perPage(20); + +$posts = $query->get(); +$info = $query->getPageInfo(); + +// $info['totalRows'] → 125 +// $info['totalPages'] → 7 +// $info['currentPage'] → 1 +// $info['previousPage'] → null +// $info['nextPage'] → 2 +``` + +--- + +## Relationships & Lazy Loading + +```php +// One-to-many +$post = DB()->post[34]; +$comments = $post->comment; // auto-fetched, cached + +// Many-to-many +$post = DB()->post[34]; +$tags = $post->tag; + +// Relate / unrelate +$post->relate($comment); +$post->unrelate($comment); +$post->unrelateAll(DB()->comment); +``` + +--- + +## Solving the N+1 Problem + +Preload related rows in a single query before iterating: + +```php +$posts = DB()->post->select()->get(); + +// Preload categories for all posts in one query +$posts->category; + +foreach ($posts as $post) { + echo $post->category->name; // no extra query +} +``` + +For many-to-many with custom ordering: + +```php +$posts = DB()->post->select()->get(); +$postTags = $posts->post_tag()->get(); +$tags = $postTags->tag()->orderBy('name ASC')->get(); +$posts->link($tags, $postTags); + +foreach ($posts as $post) { + foreach ($post->tag as $tag) { + echo $tag->name; + } +} +``` + +--- + +## Raw PDO + +Use `SQL()` for queries that fall outside SimpleCrud: + +```php +$stmt = SQL()->prepare('SELECT COUNT(*) FROM users WHERE role = ?'); +$stmt->execute(['admin']); +$count = $stmt->fetchColumn(); +``` diff --git a/docs/helpers.md b/docs/helpers.md new file mode 100644 index 0000000..f3c9270 --- /dev/null +++ b/docs/helpers.md @@ -0,0 +1,212 @@ +# Helper Functions + +All helpers are automatically available everywhere — no import needed. + +--- + +## Configuration + +### `config(string $target, string $key): mixed` + +Read a value from a config file in `config/`. + +```php +$appName = config('app', 'name'); +$dbHost = config('database', 'connections')['mysql']['host']; +``` + +Config values are cached in memory after the first read. + +--- + +### `env(string $key, mixed $default = null): mixed` + +Read an environment variable. String literals `"true"`, `"false"`, `"null"` are cast to their PHP equivalents. + +```php +$debug = env('APP_DEBUG'); // bool true / false +$dsn = env('DATABASE_URL', ''); // string +``` + +--- + +## Database + +### `DB(): \SimpleCrud\Database` + +Return the singleton SimpleCrud connection. + +```php +$posts = DB()->post->select()->orderBy('id DESC')->limit(10)->get(); +``` + +### `SQL(): \PDO` + +Return the singleton raw PDO connection. + +```php +$count = SQL()->query('SELECT COUNT(*) FROM users')->fetchColumn(); +``` + +--- + +## HTTP + +### `request(): \system\http\Request` + +Return the current HTTP request object. + +```php +$email = request()->input('email'); +``` + +### `redirect(string $url, int $status = 302): never` + +Redirect and exit. + +```php +redirect('/login'); +redirect('/profile', 301); +``` + +### `abort(int $status, string $message = ''): never` + +Send an HTTP error response and exit. + +```php +abort(404); +abort(403, 'Access denied.'); +``` + +### `json_response(mixed $data, int $status = 200): never` + +Send a JSON response and exit. + +```php +json_response(['users' => $users]); +json_response(['error' => 'Not found'], 404); +``` + +--- + +## Views & Assets + +### `view(string $view, array $data = []): void` + +Render a view template. Uses dot-notation for nested paths. + +```php +view('welcome'); +view('posts.show', ['post' => $post]); +// → resources/views/posts/show.php +``` + +### `asset(string $location): string` + +Return the URL for a public asset. + +```php + + +``` + +### `url(string $name, array $params = []): string` + +Generate a URL for a named route. + +```php +$link = url('user.show', ['id' => 42]); +// → http://localhost:4000/users/42 +``` + +--- + +## Output Escaping + +### `out(string $text): void` + +Echo an HTML-escaped string (prevents XSS). + +```php +out($user->name); +``` + +### `e(string $text): string` + +Return an HTML-escaped string. + +```php +

body) ?>

+``` + +--- + +## CSRF + +### `set_csrf(): void` + +Echo a hidden CSRF token `` tag. Call inside every HTML form. + +```html +
+ + ... +
+``` + +### `csrf_token(): string` + +Return the CSRF token string (e.g. for meta tags or AJAX headers). + +```html + +``` + +### `is_csrf_valid(): bool` + +Verify the CSRF token from the current request. Called automatically by the router for state-changing verbs. + +--- + +## Flash Messages + +### `flash(string $key, mixed $value = null): mixed` + +Store or retrieve a one-time flash value. + +```php +// Store +flash('success', 'Your profile was updated.'); +redirect('/profile'); + +// Retrieve (clears the value) +$msg = flash('success'); +``` + +### `old(string $key, mixed $default = ''): mixed` + +Retrieve old POST input (useful for repopulating forms after a failed validation). + +```php + +``` + +Populate `$_SESSION['_old_input']` in your controller before redirecting: + +```php +$_SESSION['_old_input'] = $request->body; +redirect('/form'); +``` + +--- + +## Arrays + +### `is_assoc(array $arr): bool` + +Return `true` if the array has at least one string key. + +```php +is_assoc(['a' => 1]); // true +is_assoc([1, 2, 3]); // false +``` diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..d87aa63 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,132 @@ +# Middleware + +Middleware classes run **before** the route handler executes. They are ideal for authentication checks, rate limiting, logging, and any other cross-cutting concerns. + +--- + +## Creating Middleware + +```bash +php dev make:middleware AuthMiddleware +``` + +This generates `app/web/middleware/AuthMiddleware.php`: + +```php +bearerToken(); + + if ($token === null || !$this->isValidToken($token)) { + abort(401, 'Invalid or missing API token.'); + } +} +``` diff --git a/docs/request-response.md b/docs/request-response.md new file mode 100644 index 0000000..44599f3 --- /dev/null +++ b/docs/request-response.md @@ -0,0 +1,162 @@ +# Request & Response + +--- + +## Request + +The `Request` class wraps the current HTTP request. Access it via the `request()` helper or `$this->request()` inside a controller. + +```php +$req = request(); +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$req->method` | `string` | HTTP verb in uppercase (`GET`, `POST`, …) | +| `$req->path` | `string` | URL path (e.g. `/users/5`) | +| `$req->ip` | `string` | Client IP address | +| `$req->query` | `array` | `$_GET` data | +| `$req->body` | `array` | `$_POST` data | +| `$req->files` | `array` | `$_FILES` data | +| `$req->headers` | `array` | Lowercase header names → values | + +### Reading Input + +```php +// From POST body or GET query string (body takes priority) +$name = $req->input('name'); +$name = $req->input('name', 'default'); + +// Specifically from query string +$page = $req->get('page', 1); + +// Specifically from POST body +$email = $req->post('email'); + +// Check existence +if ($req->has('token')) { ... } + +// All merged input +$all = $req->all(); + +// Only certain keys +$data = $req->only(['name', 'email']); + +// Exclude certain keys +$safe = $req->except(['password', 'csrf']); +``` + +### JSON Body + +```php +// Decode application/json request body +$payload = $req->json(); +$name = $payload['name'] ?? ''; +``` + +### Headers + +```php +$type = $req->header('content-type'); +$token = $req->header('x-api-key'); + +// Bearer token from Authorization header +$bearer = $req->bearerToken(); +``` + +### Introspection + +```php +$req->isAjax(); // true if X-Requested-With: XMLHttpRequest +$req->isJson(); // true if Content-Type contains application/json +$req->isSecure(); // true for HTTPS connections +$req->url(); // full URL of the current request +``` + +### Validation + +`validate()` returns an array of error messages keyed by field name. An **empty array** means all rules passed. + +```php +$errors = $req->validate([ + 'username' => 'required|min:3|max:50', + 'email' => 'required|email', + 'website' => 'url', + 'age' => 'numeric', +]); + +if ($errors) { + // Return errors or re-render form + $this->json(['errors' => $errors], 422); +} +``` + +**Available rules:** + +| Rule | Description | +|------|-------------| +| `required` | Field must be present and non-empty | +| `min:N` | String must be at least N characters | +| `max:N` | String must not exceed N characters | +| `email` | Must be a valid email address | +| `numeric` | Must be numeric | +| `url` | Must be a valid URL | + +Combine rules with `|`: `'required|email|max:100'` + +--- + +## Response + +### JSON + +```php +// Via helper +json_response(['status' => 'ok']); +json_response(['error' => 'Not found'], 404); + +// Via Response class +use system\http\Response; +Response::json(['data' => $items], 200); +``` + +### Redirect + +```php +redirect('/dashboard'); +redirect('/login', 302); +redirect(url('user.show', ['id' => $id])); +``` + +### Abort with Error Page + +```php +abort(403); +abort(404, 'Post not found.'); +abort(500); +``` + +### Plain / HTML Response + +```php +use system\http\Response; + +Response::make('

Hello

', 200, ['Content-Type' => 'text/html']); +``` + +### File Download + +```php +use system\http\Response; + +Response::download('/path/to/report.pdf', 'monthly-report.pdf'); +``` + +### Custom Headers + +```php +header('Cache-Control: no-cache, no-store'); +header('Content-Language: en'); +``` diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..a8e5034 --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,180 @@ +# Routing + +Routes are defined in `routes/web.php`. Each call registers the route into an internal registry; after all routes have been registered, `Route::dispatch()` (called automatically by the bootstrap) matches the current request and invokes the appropriate handler. + +--- + +## Basic Routes + +```php +use system\router\Route; + +Route::get('/hello', fn() => out('Hello, world!')); + +Route::post('/contact', [ContactController::class, 'send']); + +Route::put('/users/$id', [UserController::class, 'update']); + +Route::patch('/users/$id', [UserController::class, 'patch']); + +Route::delete('/users/$id', [UserController::class, 'destroy']); + +Route::any('/ping', fn() => out('pong')); // matches any HTTP method +``` + +--- + +## Dynamic Segments + +Prefix a route segment with `$` to capture it as a parameter. Captured values are passed as positional arguments to the handler. + +```php +Route::get('/users/$id', function (string $id): void { + out("User ID: $id"); +}); + +Route::get('/posts/$year/$slug', [PostController::class, 'show']); + +// In the controller: +// public function show(string $year, string $slug): void { ... } +``` + +--- + +## Controller Routes + +Pass a two-element array `[ClassName::class, 'methodName']`. The class is instantiated fresh for each request. + +```php +use app\web\controller\PostController; + +Route::get('/posts', [PostController::class, 'index']); +Route::get('/posts/$id', [PostController::class, 'show']); +Route::post('/posts', [PostController::class, 'store']); +Route::delete('/posts/$id', [PostController::class, 'destroy']); +``` + +--- + +## View Routes + +Shorthand for routes that only need to return a view: + +```php +Route::view('/about', 'about'); // renders resources/views/about.php +Route::view('/docs', 'docs.index', ['v' => 2]); // passes $v = 2 to the template +``` + +--- + +## Redirect Routes + +```php +Route::redirect('/old-path', '/new-path'); // 302 by default +Route::redirect('/moved', '/permanent', 301); +``` + +--- + +## Named Routes + +Assign a name to a route and generate its URL anywhere in the application. + +```php +Route::get('/users/$id', [UserController::class, 'show'], name: 'user.show'); +``` + +```php +// Generate the URL: +$url = url('user.show', ['id' => 42]); // e.g. http://localhost:4000/users/42 + +// Or directly: +$url = Route::url('user.show', ['id' => 42]); +``` + +--- + +## Route Groups + +Group routes under a shared URL prefix and/or middleware stack: + +```php +Route::group('/api/v1', function (): void { + Route::get('/users', [UserController::class, 'index']); + Route::post('/users', [UserController::class, 'store']); + Route::get('/users/$id', [UserController::class, 'show']); +}, middleware: [ApiAuthMiddleware::class]); +``` + +Groups can be nested: + +```php +Route::group('/admin', function (): void { + + Route::group('/reports', function (): void { + Route::get('/sales', [ReportController::class, 'sales']); + }); + +}, middleware: [AdminMiddleware::class]); +``` + +--- + +## Middleware on Individual Routes + +```php +Route::get('/dashboard', [DashboardController::class, 'index'], + middleware: [AuthMiddleware::class] +); +``` + +Multiple middleware classes are executed in array order: + +```php +Route::post('/admin/users', [UserController::class, 'store'], + middleware: [AuthMiddleware::class, AdminMiddleware::class] +); +``` + +--- + +## Method Spoofing (HTML Forms) + +HTML forms only support `GET` and `POST`. Add a hidden `_method` field to spoof `PUT`, `PATCH`, or `DELETE`: + +```html +
+ + + +
+``` + +--- + +## CSRF Protection + +All state-changing routes (`POST`, `PUT`, `PATCH`, `DELETE`) require a valid CSRF token. Use `set_csrf()` inside your HTML forms: + +```html +
+ + ... +
+``` + +For AJAX requests, pass the token in the `X-CSRF-TOKEN` request header: + +```js +fetch('/api/users', { + method: 'POST', + headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }, + body: JSON.stringify(data), +}); +``` + +Render the token into a meta tag in your layout: + +```html + +``` diff --git a/readme.md b/readme.md index 9794acd..b5530b5 100755 --- a/readme.md +++ b/readme.md @@ -1,409 +1,220 @@ -# PHP Micro Framework +# Micro — PHP Micro Framework -Micro is a framework for creating web applications. It has a simple, easy to understand syntax. +A lightweight, expressive PHP micro-framework for building web applications with clean **PHP 8.2+** syntax. No magic, no bloat — just a router, controllers, views, and a database ORM working together cleanly. -## Installation - -Install via composer - -```bash - composer create-project grayphp/micro my-app - cd my-app -``` - -``` -php dev start -``` +--- -``` -http://localhost:4000 -``` +## Requirements -## Usage/Examples +- **PHP 8.2+** +- Composer +- `ext-mbstring`, `ext-openssl`, `ext-pdo` -#### Abailable Routes: +--- -``` -[get,post,patch,put,any] -``` - -### Route - -```php -use system\router\Route; -Route::get('/path',[controller::class,'method']); -Route::method('/path',callback); -``` - -### Dynamic Route - -```php -Route::get('/user/$id',function($id)){ - print $id; -} - -``` - -## controller - -### Make controller - -```cli -php dev -c MyController -``` - -## Database - -Your can access to Database using DB() helper. - -### Basic CRUD: - -You can interact directly with the tables to insert/update/delete/select data: - -Use `ArrayAccess` interface to access to the data using the `id`: - -## DB instance - -```php -$db = DB(); -``` - -```php -//Get the post id = 3; -$post = $db->post[3]; -//Check if a row exists -if (isset($db->post[3])) { - echo 'exists'; -} -//Delete a post -unset($db->post[3]); -//Update a post -$db->post[3] = [ - 'title' => 'Hello world' -]; -//Insert a new post -$db->post[] = [ - 'title' => 'Hello world 2' -]; -//Tables implements the Countable interface -$totalPost = count($db->post); -``` - -### Select by other fields - -If you want to select a row by other key than `id`, just use the method `get`: - -```php -$post = $db->post->get(['slug' => 'post-slug']); -``` - -### Select or create - -Sometimes, you want to get a row or create it if it does not exist. You can do it easily with `getOrCreate` method: - -```php -$post = $db->post->getOrCreate(['slug' => 'post-slug']); -``` - -### Rows - -A `Row` object represents a database row and is used to read and modify its data: +## Installation -```php -//get a row by id -$post = $db->post[34]; -//Get/modify fields values -echo $post->title; -$post->title = 'New title'; -//Update the row into database -$post->save(); -//Remove the row in the database -$post->delete(); -//Create a new row -$newPost = $db->post->create(['title' => 'The title']); -//Insert the row in the database -$newPost->save(); +```bash +composer create-project grayphp/micro my-app +cd my-app +cp .env.example .env ``` -### Queries +Edit `.env` with your app settings, then start the development server: -A `Query` object represents a database query. SimpleCrud uses magic methods to create queries. For example `$db->post->select()` returns a new instance of a `Select` query in the tabe `post`. Other examples: `$db->comment->update()`, `$db->category->delete()`, etc... Each query has modifiers like `orderBy()`, `limit()`: +```bash +php dev serve # http://localhost:4000 +php dev serve 8080 # http://localhost:8080 +``` + +--- + +## Directory Structure + +``` +my-app/ +├── app/ +│ └── web/ +│ ├── controller/ # Application controllers +│ └── middleware/ # Request middleware +├── boot/ +│ └── bootstrap.php # Application bootstrap +├── config/ +│ ├── app.php # App configuration +│ └── database.php # Database configuration +├── docs/ # Full documentation +├── public/ +│ └── index.php # Web entry point +├── resources/ +│ └── views/ # PHP view templates +├── routes/ +│ └── web.php # Route definitions +├── storage/ +│ └── logs/ # Error logs (production) +├── system/ # Framework core (do not modify) +│ ├── console/ # CLI commands +│ ├── controller/ # Base controller +│ ├── database/ # Database drivers +│ ├── exception/ # HTTP exception classes +│ ├── helper/ # Global helper functions +│ ├── http/ # Request / Response classes +│ └── router/ # Router +├── .env # Local environment (gitignored) +├── .env.example # Template for .env +└── composer.json +``` + +--- + +## Quick Start + +### 1. Define Routes (`routes/web.php`) ```php -//Create an UPDATE query with the table post -$updateQuery = $db->post->update(['title' => 'New title']); -//Add conditions, limit, etc -$updateQuery - ->where('id = ', 23) - ->limit(1); -//get the query as string -echo $updateQuery; //UPDATE `post` ... -//execute the query and returns a PDOStatement with the result -$PDOStatement = $updateQuery(); -``` +use system\router\Route; -The method `get()` executes the query and returns the processed result of the query. For example, with `insert()` returns the id of the new row: +// Closure route +Route::get('/', fn() => view('welcome')); -```php -//insert a new post -$id = $db->post - ->insert([ - 'title' => 'My first post', - 'text' => 'This is the text of the post' - ]) - ->get(); -//Delete a post -$db->post - ->delete() - ->where('id = ', 23) - ->get(); -//Count all posts -$total = $db->post - ->selectAggregate('COUNT') - ->get(); -//note: this is the same like count($db->post) -//Sum the ids of all posts -$total = $db->post - ->selectAggregate('SUM', 'id') - ->get(); -``` +// Controller route +Route::get('/posts', [PostController::class, 'index']); +Route::get('/posts/$id', [PostController::class, 'show'], name: 'post.show'); +Route::post('/posts', [PostController::class, 'store']); -`select()->get()` returns an instance of `RowCollection` with the result: +// Dynamic segments +Route::get('/users/$id/posts/$slug', function (string $id, string $slug): void { + out("User {$id} → Post: {$slug}"); +}); -```php -$posts = $db->post - ->select() - ->where('id > ', 10) - ->orderBy('id ASC') - ->limit(100) - ->get(); -foreach ($posts as $post) { - echo $post->title; -} -``` +// Route groups with middleware +Route::group('/admin', function (): void { + Route::get('/dashboard', [AdminController::class, 'dashboard']); +}, middleware: [AuthMiddleware::class]); -If you only need the first row, use the modifier `one()`: +// View shorthand +Route::view('/about', 'about'); -```php -$post = $db->post - ->select() - ->one() - ->where('id = ', 23) - ->get(); -echo $post->title; +// Redirect +Route::redirect('/old', '/new'); ``` -`select()` has some interesting modifiers like `relatedWith()` to add automatically the `WHERE` clauses needed to select data related with other row or rowCollection: +### 2. Create a Controller -```php -//Get the post id = 23 -$post = $db->post[23]; -//Select the category related with this post -$category = $db->category - ->select() - ->relatedWith($post) - ->one() - ->get(); +```bash +php dev make:controller PostController ``` -### Query API: - -Queries use [Atlas.Query](http://atlasphp.io/cassini/query/) library to build the final queries, so you can see the documentation for all available options. - -#### Select / SelectAggregate - -| Function | Description | -| ---------------------------------------------------- | ----------------------------------------------------------------------------- | -| `one` | Select 1 result. | -| `relatedWith(Row / RowCollection / Table $relation)` | To select rows related with other rows or tables (relation added in `WHERE`). | -| `joinRelation(Table $table)` | To add a related table as `LEFT JOIN`. | -| `getPageInfo()` | Returns the info of the pagination. | -| `from` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `columns` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `join` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `catJoin` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `groupBy` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `having` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `orHaving` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `orderBy` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `catHaving` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `where` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `whereSprintf` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `catWhere` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `orWhere` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `orWhereSprintf` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `whereEquals` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `limit` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `offset` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `distinct` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `forUpdate` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `setFlag` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | -| `bindValue` | [Atlas.Query Select()](http://atlasphp.io/cassini/query/select.html) | - -#### Update - -| Function | Description | -| ---------------------------------------------------- | ----------------------------------------------------------------------------- | -| `relatedWith(Row / RowCollection / Table $relation)` | To update rows related with other rows or tables (relation added in `WHERE`). | -| `set` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | -| `setFlag` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | -| `where` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | -| `orWhere` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | -| `catWhere` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | -| `orderBy` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | -| `limit` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | -| `offset` | [Atlas.Query Update()](http://atlasphp.io/cassini/query/update.html) | - -#### Insert - -| Function | Description | -| ------------ | -------------------------------------------------------------------------------- | -| `orIgnore()` | To ignore silently the insertion on duplicated keys, instead throw an exception. | -| `set` | [Atlas.Query Insert()](http://atlasphp.io/cassini/query/insert.html) | -| `setFlag` | [Atlas.Query Insert()](http://atlasphp.io/cassini/query/insert.html) | - -#### Delete - -| Function | Description | -| ---------------------------------------------------- | ----------------------------------------------------------------------------- | -| `relatedWith(Row / RowCollection / Table $relation)` | To delete rows related with other rows or tables (relation added in `WHERE`). | -| `setFlag` | [Atlas.Query Delete()](http://atlasphp.io/cassini/query/delete.html) | -| `where` | [Atlas.Query Delete()](http://atlasphp.io/cassini/query/delete.html) | -| `orWhere` | [Atlas.Query Delete()](http://atlasphp.io/cassini/query/delete.html) | -| `catWhere` | [Atlas.Query Delete()](http://atlasphp.io/cassini/query/delete.html) | -| `orderBy` | [Atlas.Query Delete()](http://atlasphp.io/cassini/query/delete.html) | -| `limit` | [Atlas.Query Delete()](http://atlasphp.io/cassini/query/delete.html) | -| `offset` | [Atlas.Query Delete()](http://atlasphp.io/cassini/query/delete.html) | - -### Lazy loads - -Both `Row` and `RowCollection` can load automatically other related rows. Just use a property named as related table. For example: - ```php -//Get the category id=34 -$category = $db->category[34]; -//Load the posts of this category -$posts = $category->post; -//This is equivalent to: -$posts = $db->post - ->select() - ->relatedWith($category) - ->get(); -//But the result is cached so the database query is executed only the first time -$posts = $category->post; -``` +post[34]->tag->post->title; -//Get the post id=34 -//Get the tags of the post -//Then the posts related with these tags -//And finally, the titles of all these posts -``` +namespace app\web\controller; -Use magic methods to get a `Select` query returning related rows: +use system\controller\Controller; -```php -$category = $db->category[34]; -//Magic property: Returns all posts of this category: -$posts = $category->post; -//Magic method: Returns the query instead the result -$posts = $category->post() - ->where('pubdate > ', date('Y-m-d')) - ->limit(10) - ->get(); -``` +class PostController extends Controller +{ + public function index(): void + { + $posts = DB()->post->select()->orderBy('created_at DESC')->limit(10)->get(); + $this->view('posts.index', compact('posts')); + } -### Solving the n+1 problem + public function show(string $id): void + { + $post = DB()->post[(int) $id]; -The [n+1 problem](http://stackoverflow.com/questions/97197/what-is-the-n1-selects-issue) can be solved in the following way: + if ($post === null) { + $this->abort(404); + } -```php -//Get some posts -$posts = $db->post - ->select() - ->get(); -//preload all categories -$posts->category; -//now you can iterate with the posts -foreach ($posts as $post) { - echo $post->category; -} -``` - -You can perform the select by yourself to include modifiers: + $this->view('posts.show', compact('post')); + } -```php -//Get some posts -$posts = $db->post - ->select() - ->get(); -//Select the categories but ordered alphabetically descendent -$categories = $posts->category() - ->orderBy('name DESC') - ->get(); -//Save the result in the cache and link the categories with each post -$posts->link($categories); -//now you can iterate with the posts -foreach ($posts as $post) { - echo $post->category; -} -``` + public function store(): void + { + $errors = $this->request()->validate([ + 'title' => 'required|max:200', + 'body' => 'required', + ]); -For many-to-many relations, you need to do one more step: + if ($errors) { + $this->json(['errors' => $errors], 422); + } -```php -//Get some posts -$posts = $db->post - ->select() - ->get(); -//Select the post_tag relations -$tagRelations = $posts->post_tag()->get(); -//And now the tags of these relations -$tags = $tagRelations->tag() - ->orderBy('name DESC') - ->get(); -//Link the tags with posts using the relations -$posts->link($tags, $tagRelations); -//now you can iterate with the posts -foreach ($posts as $post) { - echo $post->tag; + DB()->post[] = $this->request()->only(['title', 'body']); + $this->redirect('/posts'); + } } ``` -### Relate and unrelate data - -To save related rows in the database, you need to do this: - -```php -//Get a comment -$comment = $db->comment[5]; -//Get a post -$post = $db->post[34]; -//Relate -$post->relate($comment); -//Unrelate -$post->unrelate($comment); -//Unrelate all comments of the post -$post->unrelateAll($db->comment); -``` - -### Pagination - -The `select` query has a special modifier to paginate the results: - -```php -$query = $db->post->select() - ->page(1) - ->perPage(50); -$posts = $query->get(); -//To get the page info: -$pagination = $query->getPageInfo(); -echo $pagination['totalRows']; //125 -echo $pagination['totalPages']; //3 -echo $pagination['currentPage']; //1 -echo $pagination['previousPage']; //NULL -echo $pagination['nextPage']; //2 -``` +### 3. Create a View (`resources/views/posts/index.php`) + +```html + + + + <?= e(config('app', 'name')) ?> + + +

Posts

+ + + + + +``` + +--- + +## Documentation + +| Topic | File | +|-------|------| +| Routing | [docs/routing.md](docs/routing.md) | +| Controllers | [docs/controllers.md](docs/controllers.md) | +| Request & Response | [docs/request-response.md](docs/request-response.md) | +| Database | [docs/database.md](docs/database.md) | +| Middleware | [docs/middleware.md](docs/middleware.md) | +| Helper Functions | [docs/helpers.md](docs/helpers.md) | +| CLI Commands | [docs/cli.md](docs/cli.md) | +| Configuration | [docs/configuration.md](docs/configuration.md) | + +--- + +## Key Features + +| Feature | Details | +|---------|---------| +| **PHP 8.2+** | `readonly` properties, `match`, named arguments, `declare(strict_types=1)` everywhere | +| **Router** | GET / POST / PUT / PATCH / DELETE / ANY, dynamic segments, named routes, groups, redirects, view shortcuts | +| **Controller** | Base class with `view()`, `json()`, `redirect()`, `abort()`, `request()` | +| **Request** | Immutable value object — input, headers, JSON body, validation, IP, bearer token | +| **Response** | `json()`, `redirect()`, `abort()`, `download()`, security headers | +| **Database** | SimpleCrud ORM (PDO-backed), singleton connection, MySQL + SQLite drivers | +| **CSRF** | `hash_equals()` comparison, POST/header token, `set_csrf()` / `csrf_token()` helpers | +| **Middleware** | Per-route and per-group, array-ordered stack | +| **Method Spoofing** | `_method` POST field for PUT / PATCH / DELETE from HTML forms | +| **Flash** | One-request session flash messages | +| **Error Handling** | Whoops in debug mode; structured logging + friendly error pages in production | +| **Security Headers** | Automatic on every response | +| **CLI** | `serve`, `make:controller`, `make:middleware` scaffolding commands | + +--- + +## Security + +- **CSRF tokens** — compared with `hash_equals()` (timing-safe) +- **Output escaping** — `e()` / `out()` use `htmlspecialchars` with `ENT_QUOTES | ENT_SUBSTITUTE` +- **Database** — PDO with `ERRMODE_EXCEPTION` and prepared statements via SimpleCrud +- **Security headers** — sent on every response +- **Debug mode off** — errors logged, never displayed in production + +--- + +## License + +MIT © [Sharif](https://github.com/grayphp) diff --git a/resources/views/404.php b/resources/views/404.php index 8347208..f3e6e16 100755 --- a/resources/views/404.php +++ b/resources/views/404.php @@ -1 +1,34 @@ -not found \ No newline at end of file + + + + + + 404 — Not Found + + + +
+
404
+

Page Not Found

+

The page you're looking for doesn't exist.

+ ← Go Home +
+ + diff --git a/resources/views/500.php b/resources/views/500.php new file mode 100644 index 0000000..e561729 --- /dev/null +++ b/resources/views/500.php @@ -0,0 +1,34 @@ + + + + + + 500 — Server Error + + + +
+
500
+

Server Error

+

Something went wrong on our end. Please try again later.

+ ← Go Home +
+ + diff --git a/resources/views/welcome.php b/resources/views/welcome.php index ca6c08d..a1d6eef 100755 --- a/resources/views/welcome.php +++ b/resources/views/welcome.php @@ -1,16 +1,64 @@ - - - Welcome::Micro - + Welcome — Micro + - -

WELCOME TO MICRO

+
+

Welcome to Micro

+

A lightweight, expressive PHP framework for building web applications with clean, modern PHP 8.2+ syntax.

+
PHP
+ +
- - \ No newline at end of file + diff --git a/routes/web.php b/routes/web.php index 8bb6c0e..66a8d9e 100755 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,32 @@ out('Hello world')); +| Route::get('/user/$id', fn(string $id) => out("User: $id"), name: 'user.show'); +| Route::get('/about', 'about', name: 'about'); // renders resources/views/about.php +| Route::get('/profile', [ProfileController::class, 'show'], middleware: [AuthMiddleware::class]); +| +| Route::group('/api/v1', function () { +| Route::get('/users', [UserController::class, 'index']); +| Route::post('/users', [UserController::class, 'store']); +| }); +| +*/ + +Route::get('/', function (): void { + view('welcome'); +}); diff --git a/storage/logs/.gitkeep b/storage/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/system/console/boot.php b/system/console/boot.php index b2b77cc..1bde14a 100755 --- a/system/console/boot.php +++ b/system/console/boot.php @@ -1,55 +1,169 @@ currentCommand = $this->register($commads); - $this->run(); - } else { - echo 'This script can only be run from the command line'; + if (PHP_SAPI !== 'cli') { + echo "This script must be run from the command line.\n"; + exit(1); } + + $this->handle($argv); } - function register($commads) + + private function handle(array $argv): void { - if ($commads[1] == 'serve' or $commads[1] == 'start' and !isset($commads[2])) { - echo CliColor::color("Dev Server Started Successfully.\n", 'b_black,f_green'); - system('php -S localhost:4000 -t public'); - } elseif ($commads[1] == 'serve' or $commads[1] == 'start' and isset($commads[2])) { - system("php -S localhost:{$commads[2]} -t public"); + $command = $argv[1] ?? null; + + if ($command === null || $command === '-h' || $command === '--help') { + $this->showHelp(); + return; } - // register command - $shortOptions = "c:h"; - $longOptions = ['controller:', 'help']; - return getopt($shortOptions, $longOptions); + + match ($command) { + 'serve', 'start' => $this->serve($argv[2] ?? '4000'), + 'make:controller', '-c' => $this->makeController($argv[2] ?? null), + 'make:middleware' => $this->makeMiddleware($argv[2] ?? null), + default => $this->unknownCommand($command), + }; } - function help() + + // ------------------------------------------------------------------------- + // Commands + // ------------------------------------------------------------------------- + + private function serve(string $port): void { - // help command - print "Help goes here\n"; + if (!ctype_digit($port) || (int) $port < 1 || (int) $port > 65535) { + $this->error("Invalid port number: {$port}"); + exit(1); + } + + echo CliColor::color(" Micro Framework Dev Server \n", 'b_green,f_white'); + echo CliColor::color(" Listening → http://localhost:{$port} \n\n", 'f_light_green'); + passthru("php -S localhost:{$port} -t public"); } - function run() + + private function makeController(?string $name): void { - $command = $this->currentCommand; - if (isset($command['h'])) { - $this->help(); + if ($name === null) { + $this->error("Controller name is required.\n Usage: php dev make:controller MyController"); + exit(1); } - if (isset($command['c'])) { - $this->makeController($command['c']); + + $className = ucfirst(preg_replace('/[^a-zA-Z0-9_]/', '', $name) ?? $name); + $path = __DIR__ . '/../../app/web/controller/' . $className . '.php'; + + if (file_exists($path)) { + $this->error("Controller '{$className}' already exists at app/web/controller/{$className}.php"); + exit(1); } + + file_put_contents($path, $this->controllerStub($className)); + + echo CliColor::color(" ✓ Created: app/web/controller/{$className}.php\n", 'f_green'); } - function makeController($name) + + private function makeMiddleware(?string $name): void { - print("Controller {$name} created successfully\n"); - $file = ucfirst($name); - $content = "error("Middleware name is required.\n Usage: php dev make:middleware MyMiddleware"); + exit(1); + } + + $className = ucfirst(preg_replace('/[^a-zA-Z0-9_]/', '', $name) ?? $name); + $path = __DIR__ . '/../../app/web/middleware/' . $className . '.php'; + + if (file_exists($path)) { + $this->error("Middleware '{$className}' already exists at app/web/middleware/{$className}.php"); + exit(1); + } + + file_put_contents($path, $this->middlewareStub($className)); + + echo CliColor::color(" ✓ Created: app/web/middleware/{$className}.php\n", 'f_green'); + } + + private function showHelp(): void + { + echo CliColor::color("\n Micro Framework CLI\n\n", 'f_white'); + echo CliColor::color(" Commands:\n", 'f_yellow'); + echo " php dev serve [port] Start development server (default: 4000)\n"; + echo " php dev start [port] Alias for serve\n"; + echo " php dev make:controller Name Scaffold a controller class\n"; + echo " php dev make:middleware Name Scaffold a middleware class\n"; + echo " php dev -h | --help Show this help text\n\n"; + } + + private function unknownCommand(string $command): void + { + $this->error("Unknown command '{$command}'. Run 'php dev --help' for a list of commands."); + exit(1); + } + + private function error(string $message): void + { + echo CliColor::color(" ERROR ", 'b_red,f_white') . ' ' . $message . "\n"; + } + + // ------------------------------------------------------------------------- + // Stubs + // ------------------------------------------------------------------------- + + private function controllerStub(string $name): string + { + return <<view('welcome'); + } + } + PHP; + } + + private function middlewareStub(string $name): string + { + return << $data Variables made available inside the template. + */ + protected function view(string $template, array $data = []): void + { + view($template, $data); + } + + /** + * Send a JSON response and terminate. + */ + protected function json(mixed $data, int $status = 200): never + { + Response::json($data, $status); + } + + /** + * Redirect to a URL and terminate. + */ + protected function redirect(string $url, int $status = 302): never + { + Response::redirect($url, $status); + } + + /** + * Abort with an HTTP error response and terminate. + */ + protected function abort(int $status, string $message = ''): never + { + Response::abort($status, $message); + } + + /** + * Return the current HTTP request. + */ + protected function request(): Request { - // Do something before the request is handled + return Request::current(); } -} \ No newline at end of file +} diff --git a/system/database/Database.php b/system/database/Database.php index 4cc33a3..ee97767 100755 --- a/system/database/Database.php +++ b/system/database/Database.php @@ -1,34 +1,47 @@ connection = (new Driver())->connection; - $this->sql = (new Driver())->sql; - break; - case 'mysql': - $this->connection = (new Mysql())->connection; - $this->sql = (new Mysql())->sql; - break; - default: - $this->connection = (new Mysql())->connection; - $this->sql = (new Mysql())->sql; - break; - } - } catch (\Throwable $th) { - print $th; + + $driverInstance = match ($driver) { + 'sqlite' => new Driver(), + default => new Mysql(), + }; + + $this->connection = $driverInstance->connection; + $this->sql = $driverInstance->sql; + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); } + + return self::$instance; } -} \ No newline at end of file +} diff --git a/system/database/drivers/mysql/Mysql.php b/system/database/drivers/mysql/Mysql.php index 62c1b7f..3572df1 100755 --- a/system/database/drivers/mysql/Mysql.php +++ b/system/database/drivers/mysql/Mysql.php @@ -1,30 +1,58 @@ credentials = config('database', 'connections')['mysql']; - try { - $dsn = "mysql:host={$this->credentials['host']};dbname={$this->credentials['database']};port={$this->credentials['port']};charset={$this->credentials['charset']}"; - $pdo = new \PDO($dsn, $this->credentials['username'], $this->credentials['password']); - $this->connection = new Database($pdo); - $this->sql = $pdo; - } catch (\Throwable $e) { - $file = $e->getFile(); - $line = $e->getLine(); - $msg = $e->getMessage(); - $etime = date('d/M/Y(h:i a)'); - $error = "Error: " . $msg . " file: " . $file . " line: " . $line . " date: " . $etime; - exit("
database not connected!{$error}
"); + /** @var array{host: string, port: string, database: string, username: string, password: string, charset: string, unix_socket: string} $cfg */ + $cfg = config('database', 'connections')['mysql']; + + $dsn = $this->buildDsn($cfg); + + $pdo = new PDO($dsn, $cfg['username'], $cfg['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + $this->connection = new Database($pdo); + $this->sql = $pdo; + } + + /** @param array $cfg */ + private function buildDsn(array $cfg): string + { + if (!empty($cfg['unix_socket'])) { + return sprintf( + 'mysql:unix_socket=%s;dbname=%s;charset=%s', + $cfg['unix_socket'], + $cfg['database'], + $cfg['charset'], + ); } + + return sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $cfg['host'], + $cfg['port'], + $cfg['database'], + $cfg['charset'], + ); } -} \ No newline at end of file +} diff --git a/system/database/drivers/sqlite/Driver.php b/system/database/drivers/sqlite/Driver.php index e6f8269..60a979a 100755 --- a/system/database/drivers/sqlite/Driver.php +++ b/system/database/drivers/sqlite/Driver.php @@ -1,35 +1,52 @@ resolvePath(); + + $this->ensureDirectoryExists($path); + + $pdo = new PDO('sqlite:' . $path, options: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + $this->connection = new Database($pdo); + $this->sql = $pdo; + } + + private function resolvePath(): string + { + $configured = config('database', 'connections')['sqlite']['database'] ?? ''; + + return $configured !== '' ? $configured : DATABASE_PATH . 'database.sqlite'; + } + + private function ensureDirectoryExists(string $path): void { + $dir = dirname($path); - try { - if (!file_exists(SQLITE_PATH)) { - touch(SQLITE_PATH); - } - $pdo = new \PDO('sqlite:' . SQLITE_PATH, '', '', array( - \PDO::ATTR_EMULATE_PREPARES => false, - \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, - \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC - )); - $this->connection = new Database($pdo); - $this->sql = $pdo; - } catch (\Exception $e) { - $file = $e->getFile(); - $line = $e->getLine(); - $msg = $e->getMessage(); - $etime = date('d/M/Y(h:i a)'); - $error = "Error: " . $msg . " file: " . $file . " line: " . $line . " date: " . $etime; - exit("
database not connected!{$error}
"); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); } } -} \ No newline at end of file +} diff --git a/system/exception/ForbiddenException.php b/system/exception/ForbiddenException.php new file mode 100644 index 0000000..89b4e7c --- /dev/null +++ b/system/exception/ForbiddenException.php @@ -0,0 +1,13 @@ +defaultMessage(), $statusCode, $previous); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + private function defaultMessage(): string + { + return match ($this->statusCode) { + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 419 => 'CSRF Token Mismatch', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', + 503 => 'Service Unavailable', + default => 'HTTP Error', + }; + } +} diff --git a/system/exception/NotFoundException.php b/system/exception/NotFoundException.php new file mode 100644 index 0000000..e9fde31 --- /dev/null +++ b/system/exception/NotFoundException.php @@ -0,0 +1,13 @@ +request()->validate(['email' => 'required|email']); + * if ($errors) { + * throw new ValidationException($errors); + * } + */ +class ValidationException extends HttpException +{ + /** @param array> $errors */ + public function __construct(private readonly array $errors, string $message = 'Validation Failed') + { + parent::__construct(422, $message); + } + + /** @return array> */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/system/helper/global.php b/system/helper/global.php index 02656d6..dc4d122 100755 --- a/system/helper/global.php +++ b/system/helper/global.php @@ -1,75 +1,278 @@ true, + 'false', '(false)' => false, + 'null', '(null)' => null, + 'empty', '(empty)' => '', + default => $value, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Database +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Return the SimpleCrud database connection (singleton per request). + */ +function DB(): \SimpleCrud\Database +{ + return \system\database\Database::getInstance()->connection; } -function DB() + +/** + * Return the raw PDO connection (singleton per request). + */ +function SQL(): \PDO { - return (new \system\database\Database())->connection; + return \system\database\Database::getInstance()->sql; } -function SQL() + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Return the current HTTP Request instance. + */ +function request(): \system\http\Request { - return (new \system\database\Database())->sql; + return \system\http\Request::current(); } -function out($text) + +/** + * Redirect to a URL and exit. + */ +function redirect(string $url, int $status = 302): never { - echo htmlspecialchars($text); + \system\http\Response::redirect($url, $status); } -function set_csrf() + +/** + * Abort with an HTTP error response and exit. + */ +function abort(int $status, string $message = ''): never { - if (!isset($_SESSION["csrf"])) { - $_SESSION["csrf"] = bin2hex(random_bytes(50)); - } - echo ''; + \system\http\Response::abort($status, $message); } -function is_csrf_valid() + +/** + * Send a JSON response and exit. + */ +function json_response(mixed $data, int $status = 200): never { - if (!isset($_SESSION['csrf']) || !isset($_POST['csrf'])) { - return false; - } - if ($_SESSION['csrf'] != $_POST['csrf']) { - return false; + \system\http\Response::json($data, $status); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Views & assets +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Render a view template. + * + * The $view argument uses dot-notation: "admin.users.index" + * resolves to resources/views/admin/users/index.php + * + * @param array $data Variables extracted into the template scope. + * @throws \RuntimeException when the view file does not exist. + */ +function view(string $view, array $data = []): void +{ + $file = VIEWS_PATH . str_replace('.', '/', $view) . '.php'; + + if (!file_exists($file)) { + throw new \RuntimeException("View '{$view}' not found at '{$file}'."); } - return true; + + extract($data, EXTR_SKIP); + require $file; +} + +/** + * Return the public URL for an asset. + * + * @example + */ +function asset(string $location): string +{ + $base = rtrim($_ENV['APP_URL'] ?? '', '/'); + return $base . '/asset/' . ltrim($location, '/'); +} + +/** + * Generate a full URL for a named route. + * + * @param array $params Dynamic segment values. + */ +function url(string $name, array $params = []): string +{ + return \system\router\Route::url($name, $params); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Output escaping +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Echo an HTML-escaped string (safe for use inside HTML attributes and content). + */ +function out(string $text): void +{ + echo htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +/** + * Return an HTML-escaped string. + */ +function e(string $text): string +{ + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// CSRF +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Echo a hidden CSRF token input field. + * A new token is generated if one does not yet exist in the session. + */ +function set_csrf(): void +{ + echo ''; } -function view($view, $data = []) + +/** + * Return the current CSRF token string, generating one if needed. + */ +function csrf_token(): string { - $file = str_replace('.', '/', $view); - $file = VIEWS_PATH . $file . '.php'; - if (file_exists($file)) { - extract($data); - include $file; - } else { - throw new Exception("{$view} View Not Found", 1); + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); } + + return $_SESSION['csrf']; } -function asset($location) + +/** + * Validate the CSRF token supplied with the current request. + * + * Accepts the token in: + * - POST field "csrf" + * - Request header "X-CSRF-TOKEN" + */ +function is_csrf_valid(): bool { - $url = (substr($_ENV['APP_URL'], -1) == '/') ? $_ENV['APP_URL'] : $_ENV['APP_URL'] . '/'; - echo $url . 'asset/' . $location; + $sessionToken = $_SESSION['csrf'] ?? ''; + $requestToken = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + + if ($sessionToken === '' || $requestToken === '') { + return false; + } + + return hash_equals($sessionToken, (string) $requestToken); } -function env($key, $value = null) +// ───────────────────────────────────────────────────────────────────────────── +// Session flash & old input +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Store or retrieve a flash message. + * + * - Set: flash('success', 'Saved!') + * - Get: flash('success') → reads and clears the value + */ +function flash(string $key, mixed $value = null): mixed { - $_ENV[$key] = (isset($_ENV[$key])) ? $_ENV[$key] : null; if ($value !== null) { - return $_ENV[$key] = $value; - } else { - return $_ENV[$key]; + $_SESSION['_flash'][$key] = $value; + return null; } + + $stored = $_SESSION['_flash'][$key] ?? null; + unset($_SESSION['_flash'][$key]); + return $stored; } -function is_assoc(array $arr) + +/** + * Retrieve old input from the previous POST request (form repopulation). + */ +function old(string $key, mixed $default = ''): mixed +{ + return $_SESSION['_old_input'][$key] ?? $default; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Arrays +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Determine whether an array is associative (i.e. has at least one string key). + */ +function is_assoc(array $arr): bool { - if (array() === $arr) return false; + if ($arr === []) { + return false; + } + return array_keys($arr) !== range(0, count($arr) - 1); -} \ No newline at end of file +} diff --git a/system/http/Request.php b/system/http/Request.php new file mode 100644 index 0000000..c877b34 --- /dev/null +++ b/system/http/Request.php @@ -0,0 +1,292 @@ + */ + public readonly array $query; + + /** @var array */ + public readonly array $body; + + /** @var array */ + public readonly array $files; + + /** @var array Lowercase header names */ + public readonly array $headers; + + private static ?self $instance = null; + + public function __construct() + { + $this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $this->path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/'; + $this->query = $_GET; + $this->body = $_POST; + $this->files = $_FILES; + $this->ip = $this->resolveIp(); + $this->headers = $this->parseHeaders(); + } + + /** + * Returns the singleton instance for the current request. + */ + public static function current(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + // ------------------------------------------------------------------------- + // Input access + // ------------------------------------------------------------------------- + + /** + * Get a value from POST body or GET query string (body takes priority). + */ + public function input(string $key, mixed $default = null): mixed + { + return $this->body[$key] ?? $this->query[$key] ?? $default; + } + + /** + * Get a value from the query string. + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->query[$key] ?? $default; + } + + /** + * Get a value from the POST body. + */ + public function post(string $key, mixed $default = null): mixed + { + return $this->body[$key] ?? $default; + } + + /** + * Check if an input key exists in POST or GET. + */ + public function has(string $key): bool + { + return array_key_exists($key, $this->body) || array_key_exists($key, $this->query); + } + + /** + * All merged input (query + body). + * + * @return array + */ + public function all(): array + { + return array_merge($this->query, $this->body); + } + + /** + * Only the specified keys from all merged input. + * + * @param list $keys + * @return array + */ + public function only(array $keys): array + { + return array_intersect_key($this->all(), array_flip($keys)); + } + + /** + * All merged input except the specified keys. + * + * @param list $keys + * @return array + */ + public function except(array $keys): array + { + return array_diff_key($this->all(), array_flip($keys)); + } + + /** + * Decode a JSON request body. + * + * @return array + */ + public function json(): array + { + $raw = file_get_contents('php://input'); + + if ($raw === '' || $raw === false) { + return []; + } + + return json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } + + // ------------------------------------------------------------------------- + // Headers + // ------------------------------------------------------------------------- + + public function header(string $key, ?string $default = null): ?string + { + $normalized = strtolower(str_replace(['_', ' '], '-', $key)); + return $this->headers[$normalized] ?? $default; + } + + /** + * Extract the Bearer token from the Authorization header. + */ + public function bearerToken(): ?string + { + $auth = $this->header('authorization'); + + if ($auth !== null && str_starts_with($auth, 'Bearer ')) { + return substr($auth, 7); + } + + return null; + } + + // ------------------------------------------------------------------------- + // Introspection + // ------------------------------------------------------------------------- + + public function isAjax(): bool + { + return $this->header('x-requested-with') === 'XMLHttpRequest'; + } + + public function isJson(): bool + { + return str_contains($this->header('content-type', ''), 'application/json'); + } + + public function isSecure(): bool + { + return (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') + || ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443); + } + + public function url(): string + { + $scheme = $this->isSecure() ? 'https' : 'http'; + return $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . ($_SERVER['REQUEST_URI'] ?? '/'); + } + + // ------------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------------- + + /** + * Validate input against a simple rule set. + * Returns an array of error messages keyed by field name. + * An empty array means all fields are valid. + * + * Supported rules: required | min:N | max:N | email | numeric | url + * + * Example: + * $errors = $request->validate([ + * 'email' => 'required|email', + * 'password' => 'required|min:8', + * ]); + * + * @param array> $rules + * @return array> + */ + public function validate(array $rules): array + { + $errors = []; + $data = $this->all(); + + foreach ($rules as $field => $ruleString) { + $fieldRules = is_array($ruleString) + ? $ruleString + : explode('|', $ruleString); + + $value = $data[$field] ?? null; + + foreach ($fieldRules as $rule) { + [$ruleName, $ruleParam] = array_pad(explode(':', (string) $rule, 2), 2, null); + + $failed = match ($ruleName) { + 'required' => $value === null || $value === '', + 'min' => is_string($value) && mb_strlen($value) < (int) $ruleParam, + 'max' => is_string($value) && mb_strlen($value) > (int) $ruleParam, + 'email' => $value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL), + 'numeric' => $value !== null && $value !== '' && !is_numeric($value), + 'url' => $value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_URL), + default => false, + }; + + if ($failed) { + $errors[$field][] = match ($ruleName) { + 'required' => "{$field} is required.", + 'min' => "{$field} must be at least {$ruleParam} characters.", + 'max' => "{$field} must not exceed {$ruleParam} characters.", + 'email' => "{$field} must be a valid email address.", + 'numeric' => "{$field} must be numeric.", + 'url' => "{$field} must be a valid URL.", + default => "{$field} failed validation rule '{$ruleName}'.", + }; + } + } + } + + return $errors; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private function resolveIp(): string + { + foreach (['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) { + $raw = $_SERVER[$key] ?? ''; + + if ($raw !== '') { + $ip = trim(explode(',', $raw)[0]); + + if (filter_var($ip, FILTER_VALIDATE_IP) !== false) { + return $ip; + } + } + } + + return '0.0.0.0'; + } + + /** @return array */ + private function parseHeaders(): array + { + $headers = []; + + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = strtolower(str_replace('_', '-', substr($key, 5))); + $headers[$name] = (string) $value; + } elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) { + $name = strtolower(str_replace('_', '-', $key)); + $headers[$name] = (string) $value; + } + } + + return $headers; + } +} diff --git a/system/http/Response.php b/system/http/Response.php new file mode 100644 index 0000000..7a071d0 --- /dev/null +++ b/system/http/Response.php @@ -0,0 +1,126 @@ + $headers Additional response headers + */ + public static function json(mixed $data, int $status = 200, array $headers = []): never + { + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + + foreach ($headers as $name => $value) { + header("{$name}: {$value}"); + } + + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + exit(); + } + + /** + * Send a redirect response and exit. + */ + public static function redirect(string $url, int $status = 302): never + { + http_response_code($status); + header("Location: {$url}"); + exit(); + } + + /** + * Abort with an HTTP error status. + * Renders a matching view file from resources/views/{status}.php when available. + */ + public static function abort(int $status, string $message = ''): never + { + http_response_code($status); + + $default = [ + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 419 => 'CSRF Token Mismatch', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', + 503 => 'Service Unavailable', + ]; + + $text = $message ?: ($default[$status] ?? 'Error'); + $viewFile = defined('VIEWS_PATH') ? VIEWS_PATH . "{$status}.php" : null; + + if ($viewFile !== null && file_exists($viewFile)) { + include $viewFile; + } else { + echo "

{$status} — {$text}

"; + } + + exit(); + } + + /** + * Send a plain text / HTML response and exit. + * + * @param array $headers + */ + public static function make(string $body, int $status = 200, array $headers = []): never + { + http_response_code($status); + + foreach ($headers as $name => $value) { + header("{$name}: {$value}"); + } + + echo $body; + exit(); + } + + /** + * Serve a file as a download attachment and exit. + */ + public static function download(string $filePath, ?string $filename = null): never + { + if (!is_file($filePath)) { + self::abort(404, 'File not found.'); + } + + $filename ??= basename($filePath); + $size = filesize($filePath); + + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . addslashes($filename) . '"'); + header('Content-Length: ' . ($size !== false ? (string) $size : '')); + header('Pragma: no-cache'); + header('Cache-Control: must-revalidate'); + readfile($filePath); + exit(); + } + + /** + * Set a security-oriented response header (call before any output). + */ + public static function withSecurityHeaders(): void + { + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: SAMEORIGIN'); + header('X-XSS-Protection: 1; mode=block'); + header('Referrer-Policy: strict-origin-when-cross-origin'); + } +} diff --git a/system/router/Route.php b/system/router/Route.php index 4c2c2b0..bbed819 100755 --- a/system/router/Route.php +++ b/system/router/Route.php @@ -1,153 +1,283 @@ ..., [MyMiddleware::class]) + */ +final class Route { - static function view($route, $view, $data = []) + /** @var array}> */ + private static array $routes = []; + + /** @var array name => path */ + private static array $namedRoutes = []; + + private static string $groupPrefix = ''; + + /** @var list */ + private static array $groupMiddleware = []; + + // ------------------------------------------------------------------------- + // Public route registration + // ------------------------------------------------------------------------- + + public static function get(string $path, array|callable $handler, ?string $name = null, array $middleware = []): void { - self::view_route($route, $view, $data); + self::add('GET', $path, $handler, $name, $middleware); } - static function get($route, $controller) + public static function post(string $path, array|callable $handler, ?string $name = null, array $middleware = []): void { - if ($_SERVER['REQUEST_METHOD'] === 'GET') { - self::route($route, $controller); - } + self::add('POST', $path, $handler, $name, $middleware); } - static function post($route, $controller) + + public static function put(string $path, array|callable $handler, ?string $name = null, array $middleware = []): void { - if ($_SERVER['REQUEST_METHOD'] === 'POST' && is_csrf_valid()) { - self::route($route, $controller); - } + self::add('PUT', $path, $handler, $name, $middleware); } - static function put($route, $controller) + + public static function patch(string $path, array|callable $handler, ?string $name = null, array $middleware = []): void { - if ($_SERVER['REQUEST_METHOD'] === 'PUT') { - self::route($route, $controller); - } + self::add('PATCH', $path, $handler, $name, $middleware); } - static function patch($route, $controller) + + public static function delete(string $path, array|callable $handler, ?string $name = null, array $middleware = []): void { - if ($_SERVER['REQUEST_METHOD'] === 'PATCH') { - self::route($route, $controller); - } + self::add('DELETE', $path, $handler, $name, $middleware); } - static function delete($route, $controller) + + public static function any(string $path, array|callable $handler, ?string $name = null, array $middleware = []): void { - if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { - self::route($route, $controller); + foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $verb) { + self::add($verb, $path, $handler, $name, $middleware); } } - static function any($route, $controller) + + /** + * Shorthand: register a GET route that renders a view directly. + */ + public static function view(string $path, string $view, array $data = [], ?string $name = null): void { + self::add('GET', $path, static fn() => view($view, $data), $name, []); + } - self::route($route, $controller); + /** + * Register a permanent or temporary redirect. + */ + public static function redirect(string $from, string $to, int $status = 302): void + { + self::add('GET', $from, static function () use ($to, $status): never { + http_response_code($status); + header("Location: $to"); + exit(); + }, null, []); } - static function route($route, $controllers) + /** + * Group routes under a common URL prefix and/or middleware set. + * + * @param list $middleware + */ + public static function group(string $prefix, callable $callback, array $middleware = []): void { - if (is_array($controllers)) { - if (is_assoc($controllers)) { - $method = (isset($controllers[0])) ? $controllers[0] : 'index'; - $controller = $controllers['controller']; - $middleware = (isset($controllers['middleware'])) ? $controllers['middleware'] : null; - } else { - $controller = $controllers[0]; - $method = (isset($controllers[1])) ? $controllers[1] : 'index'; + $prevPrefix = self::$groupPrefix; + $prevMiddleware = self::$groupMiddleware; + + self::$groupPrefix = $prevPrefix . '/' . trim($prefix, '/'); + self::$groupMiddleware = array_merge($prevMiddleware, $middleware); + + $callback(); + + self::$groupPrefix = $prevPrefix; + self::$groupMiddleware = $prevMiddleware; + } + + // ------------------------------------------------------------------------- + // Named route URL generation + // ------------------------------------------------------------------------- + + /** + * Generate a full URL for a named route. + * + * @param array $params Dynamic segment values keyed by name (e.g. ['id' => 5]) + */ + public static function url(string $name, array $params = []): string + { + if (!isset(self::$namedRoutes[$name])) { + throw new \RuntimeException("Named route '{$name}' not found."); + } + + $path = self::$namedRoutes[$name]; + + foreach ($params as $key => $value) { + $path = str_replace('$' . $key, (string) $value, $path); + } + + $base = rtrim($_ENV['APP_URL'] ?? '', '/'); + return $base . $path; + } + + // ------------------------------------------------------------------------- + // Dispatch + // ------------------------------------------------------------------------- + + /** + * Match the current HTTP request against registered routes and execute the handler. + * Call this once after all routes have been registered. + */ + public static function dispatch(): never + { + $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + + // HTML form method-spoofing via hidden _method field + if ($method === 'POST' && isset($_POST['_method'])) { + $spoofed = strtoupper((string) $_POST['_method']); + if (in_array($spoofed, ['PUT', 'PATCH', 'DELETE'], true)) { + $method = $spoofed; } - } else { - $callback = $controllers; } - $callback = (isset($callback)) ? $callback : null; + $requestPath = self::resolveRequestPath(); + + foreach (self::$routes as $route) { + if ($route['method'] !== $method) { + continue; + } + + $params = self::matchPath($route['path'], $requestPath); - if ($route == "/404") { - $file = VIEWS_PATH . "/$controllers.php"; - if (file_exists($file)) { - include $file; + if ($params === false) { + continue; + } + + // CSRF protection for state-changing verbs + if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + if (!is_csrf_valid()) { + http_response_code(419); + echo '

419 — CSRF Token Mismatch

'; + exit(); + } + } + + // Run middleware stack + foreach ($route['middleware'] as $middlewareClass) { + (new $middlewareClass())->handle(); + } + + // Invoke handler + if (is_callable($route['handler'])) { + call_user_func_array($route['handler'], $params); } else { - echo "404 Not Found"; + [$class, $methodName] = $route['handler']; + call_user_func_array([new $class(), $methodName], $params); } - header("HTTP/1.0 404 Not Found"); - exit(); - } - $actual_link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"; - $rooturl = rtrim($_ENV['APP_URL'], '/'); - $request_url = filter_var(explode($rooturl, $actual_link)[1], FILTER_SANITIZE_URL); - //$request_url = rtrim($request_url, '/'); - $request_url = strtok($request_url, '?'); - $route_parts = explode('/', $route); - $request_url_parts = explode('/', $request_url); - array_shift($route_parts); - array_shift($request_url_parts); - if ($route_parts[0] == '' && count($request_url_parts) == 0) { + exit(); } - if (count($route_parts) != count($request_url_parts)) { - return; - } - $parameters = []; - for ($__i__ = 0; $__i__ < count($route_parts); $__i__++) { - $route_part = $route_parts[$__i__]; - if (preg_match("/^[$]/", $route_part)) { - $route_part = ltrim($route_part, '$'); - array_push($parameters, $request_url_parts[$__i__]); - $$route_part = $request_url_parts[$__i__]; - } else if ($route_parts[$__i__] != $request_url_parts[$__i__]) { - return; - } - } - // Callback function - if (is_callable($callback)) { - call_user_func_array($callback, $parameters); - exit(); + + // No route matched + self::notFound(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static function add( + string $method, + string $path, + array|callable $handler, + ?string $name, + array $middleware, + ): void { + $prefix = self::$groupPrefix; + $fullPath = '/' . trim($prefix . '/' . ltrim($path, '/'), '/'); + + if ($fullPath === '') { + $fullPath = '/'; } - // Controller function - if (isset($middleware)) { - $middleware = new $middleware; - $middleware->handle(); + + self::$routes[] = [ + 'method' => strtoupper($method), + 'path' => $fullPath, + 'handler' => $handler, + 'middleware' => array_merge(self::$groupMiddleware, $middleware), + ]; + + if ($name !== null) { + self::$namedRoutes[$name] = $fullPath; } - call_user_func_array([new $controller, $method], $parameters); - exit(); } - static function view_route($route, $view, $data = []) + /** + * Returns an ordered list of captured parameter values, or false on no-match. + * + * @return list|false + */ + private static function matchPath(string $routePath, string $requestPath): array|false { - $callback = $view; - $actual_link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"; - $rooturl = rtrim($_ENV['APP_URL'], '/'); - $request_url = filter_var(explode($rooturl, $actual_link)[1], FILTER_SANITIZE_URL); - $request_url = rtrim($request_url, '/'); - $request_url = strtok($request_url, '?'); - $route_parts = explode('/', $route); - $request_url_parts = explode('/', $request_url); - array_shift($route_parts); - array_shift($request_url_parts); - if ($route_parts[0] == '' && count($request_url_parts) == 0) { - VIEWS_PATH . "/$view"; - exit(); + if ($routePath === $requestPath) { + return []; } - if (count($route_parts) != count($request_url_parts)) { - return; + + $routeParts = explode('/', trim($routePath, '/')); + $requestParts = explode('/', trim($requestPath, '/')); + + if (count($routeParts) !== count($requestParts)) { + return false; } - $parameters = []; - for ($__i__ = 0; $__i__ < count($route_parts); $__i__++) { - $route_part = $route_parts[$__i__]; - if (preg_match("/^[$]/", $route_part)) { - $route_part = ltrim($route_part, '$'); - array_push($parameters, $request_url_parts[$__i__]); - $$route_part = $request_url_parts[$__i__]; - } else if ($route_parts[$__i__] != $request_url_parts[$__i__]) { - return; + + $params = []; + + foreach ($routeParts as $i => $segment) { + if (str_starts_with($segment, '$')) { + $params[] = $requestParts[$i]; + } elseif ($segment !== $requestParts[$i]) { + return false; } } - // Callback function - if (is_callable($callback)) { - call_user_func_array($callback, $parameters); - exit(); + + return $params; + } + + /** + * Derive the path portion of the current request URI, + * stripped of the APP_URL base path when running in a subdirectory. + */ + private static function resolveRequestPath(): string + { + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + $path = parse_url($uri, PHP_URL_PATH) ?? '/'; + + // Strip subdirectory base if APP_URL contains a path component + $base = parse_url($_ENV['APP_URL'] ?? '', PHP_URL_PATH) ?? ''; + if ($base !== '' && str_starts_with($path, $base)) { + $path = substr($path, strlen($base)); + } + + return '/' . ltrim($path, '/'); + } + + private static function notFound(): never + { + http_response_code(404); + + $viewFile = VIEWS_PATH . '404.php'; + if (file_exists($viewFile)) { + include $viewFile; + } else { + echo '

404 — Not Found

'; } - view($view, $data); + exit(); } -} \ No newline at end of file +}