diff --git a/.env b/.env index 257a014..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 \ No newline at end of file +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 "
= e($comment->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 + + + +A lightweight, expressive PHP framework for building web applications with clean, modern PHP 8.2+ syntax.
+