Current MageWork Version: 2.7.0

# Installation

- [Requirements](#requirements)
  - [PHP](#php)
  - [Server](#server)
- [Environment variables](#environment-variables)
- [Root directory](#root-directory)
- [Writable directories](#writable-directories)
- [Examples](#examples)
  - [Built-in PHP server](#built-in-php-server)
  - [Apache](#apache)
  - [Nginx](#nginx)
  - [Caddy](#caddy)
  - [CLI](#cli)

## Requirements

### PHP

A PHP version **8.4 or higher** is required.

PHP Extensions:

- ext-fileinfo (to read an asset static file)
- ext-gd (to generate a Captcha)
- ext-openssl (to encrypt and decrypt data)

```
sudo apt install php8.4-gd php8.4-common
```

### Server

MageWork is compatible with any web server.

## Environment variables

- **MW_DEVELOPER_MODE**: Display PHP error. Always set `0` in production. Default value if missing is `0`.
- **MW_ENVIRONMENT**: The environment name (local, prod, staging...). This variable is used to read the local configuration file in the `etc` directory: `etc/local.{MW_ENVIRONMENT}.php`. If the variable is missing, the local configuration file will be `etc/local.php`.
- **MW_HOST**: Force the HTTP host. Mainly useful on the command line, where there is no `Host` header (the CLI entry point sets it from its first argument).

## Root directory

Configure the web server to serve the `pub` directory. A `pub/.htaccess` file is already provided for Apache (it needs `AllowOverride All`).

## Writable directories

MageWork creates a `var/` directory at the project root and must be able to write to it:

- `var/cache` — cache files
- `var/session` — session files
- `var/log` — log files
- `var/encryption` — encryption key (`0400`, created once)

The web server user (and the CLI user) must have write access to `var/`.

## Examples

### Built-in PHP server

#### Unix

From the MageWork root folder:

```
sudo php -S localhost.magework:80 -t pub
```

With optional environment variables:

```
sudo MW_ENVIRONMENT=local MW_DEVELOPER_MODE=1 php -S localhost.magework:80 -t pub
```

#### Windows

Add an executable `bat` file to the MageWork root directory:

```
:: server.bat

@echo off

:: Environment-specific configuration (optional)
:: set MW_ENVIRONMENT=local

:: Display PHP error (optional)
:: set MW_DEVELOPER_MODE=1

php -S localhost.magework:80 -t pub
pause
```

> To serve MageWork quickly without adding a new host, serve on 127.0.0.1:80 (or another port if port 80 is busy).

### Apache

```
<VirtualHost *:80>
    ServerName localhost.magework
    DocumentRoot /var/www/magework/pub

    # Display PHP error (optional)
    # SetEnv MW_DEVELOPER_MODE 1

    # Environment-specific configuration (optional)
    # SetEnv MW_ENVIRONMENT local

    <Directory /var/www/magework/pub>
        Options Indexes FollowSymLinks MultiViews
        AllowOverride All
        Require all granted
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/magework.log
</VirtualHost>
```

### Nginx

```
server {
    listen 80;
    listen [::]:80;

    root /var/www/magework/pub;
    server_name localhost.magework;
    index index.php;

    charset utf-8;
    autoindex off;

    location ~ /\.ht {
        deny all;
    }

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;

        # Display PHP error (optional)
        # fastcgi_param MW_DEVELOPER_MODE 1;

        # Environment-specific configuration (optional)
        # fastcgi_param MW_ENVIRONMENT local;
    }
}
```

### Caddy

```
localhost.magework {
    root * /var/www/magework/pub
    try_files {path} {path}/ /index.php?{query}
    php_fastcgi unix//var/run/php/php8.4-fpm.sock {
        env MW_DEVELOPER_MODE 1
        env MW_ENVIRONMENT local
    }
    file_server
}
```

### CLI

You can display a page directly in the console, for debugging or CI/CD testing.

```
php pub/index.php {host} {path} {developer_mode} {environment}
```

Example:

```
php pub/index.php localhost.magework /documentation/installation.html 1 local
```


# Configuration

- [Configuration files](#configuration-files)
- [Package](#package)
- [Object data](#object-data)
- [Database](#database)
- [Session cookie](#session-cookie)
- [Secured protocol port](#secured-protocol-port)
- [Forms](#forms)

## Configuration files

Configuration is split across two files in the `etc` directory:

- `config.php` — shared settings committed to the repository (defaults, charset, mail sender, etc.)
- `local.php` — environment-specific or sensitive settings, **not committed** (database credentials, HTTPS, etc.)

> Files are loaded in the following order, each overriding the previous:
> **packages/{package}/etc/*.php → etc/config.php → etc/local.php**

> Every `.php` file in a package's `etc/` directory is loaded and deep-merged, whatever its name. Splitting the configuration into `pages.php`, `blocks.php`, `hooks.php`… is only a convention — a single `config.php` would work the same way.

Copy `etc/local.php.sample` to `etc/local.php` (or `etc/local.{MW_ENVIRONMENT}.php` if `MW_ENVIRONMENT` is defined) and fill in your environment values.

> **MW_ENVIRONMENT** is an environment variable. See Installation.

## Package

Access to a package is done in the configuration file. The package host must match the server host for MageWork to load the corresponding package.

You can specify multiple hosts to access the same package, or develop multiple packages with their own host.

```php
<?php
// etc/local.php

$config = [
    'packages' => [
        'localhost.magework' => [ // HTTP Host
            'Magework' => '', // Package name (directory) and URL prefix (empty = no prefix)
        ],
    ],
    /* ... */
];
```

See Add a new package page for more information about packages.

> If you are using a port other than 80 or 443, the port must be included in the host, e.g. : "127.0.0.1:8080".

## Object data

In the configuration file, you can define all the environment information that needs to be passed to your own objects.

For example, to pass a variable to page-type objects in a package:

```php
<?php
// etc/config.php

$config = [
    /* ... */
    'Acme' => [ // Package name
        Core_Page::TYPE => [ // Object type
            'default' => [ // All pages (default value)
                'email' => 'default@example.com',
            ],
            '/contact.html' => [ // Specific page
                'email' => 'contact@example.com', // Override the default value
            ],
        ],
    ],
    /* ... */
];
```

## Database

To interact with a database, you need to configure your database connection. Shared settings go in `config.php`, and environment-specific credentials go in `local.php`:

```php
<?php
// etc/config.php

$config = [
    /* ... */
    'default' => [ // "default" is the configuration for all packages. Use the package name for a specific configuration.
        Core_Model::TYPE => [
            'database' => [
                'db_charset' => 'utf8mb4',
                'lc_time_names' => 'en_US', // Language used to display day and month names and abbreviations
                'time_zone' => '+00:00', // Affects display and storage of time values that are zone-sensitive
            ],
        ],
    ],
    /* ... */
];
```

```php
<?php
// etc/local.php

$config = [
    /* ... */
    'default' => [
        Core_Model::TYPE => [
            'database' => [
                'db_host' => '',
                'db_username' => '',
                'db_password' => '',
                'db_database' => '',
            ],
        ],
    ],
    /* ... */
];
```

This provides the connection information to the `Core_Model_Database` class, for all packages.

> You are able to configure specific settings per package. See data assignment.

## Session cookie

If you need to use a session, the following configurations are available:

```php
<?php
// etc/config.php

$config = [
    /* ... */
    'app' => [
        'session_lifetime' => 3600, // Cookie lifetime in seconds
        'cookie_same_site' => 'Lax', // None, Lax, Strict
        'cookie_http_only' => true,
    ],
    /* ... */
];
```

> These configurations are shared by all packages.

## Secured protocol port

`App::isSsl()` determines whether the current context is secure. By default, it compares the server port against `443`. If you're using a certificate on a different port, you can override the expected port:

```php
<?php
// etc/local.php

$config = [
    /* ... */
    'app' => [
        'secured_port' => 8443,
    ],
    /* ... */
];
```

You can also force the protocol explicitly with `https`, bypassing the port check entirely. This is useful when the application runs behind a reverse proxy that terminates SSL — the app sees port 80, but the connection is served over HTTPS:

```php
<?php
// etc/local.php

$config = [
    /* ... */
    'app' => [
        'https' => true, // Force HTTPS regardless of server port
    ],
    /* ... */
];
```

> When `https` is set, it takes priority over `secured_port`: no port comparison is performed.

## Forms

To manage forms and send emails, you can configure the contact settings. Default sender values go in `config.php`, while activation is controlled per environment in `local.php`:

```php
<?php
// etc/config.php

$config = [
    /* ... */
    'default' => [ // "default" is the configuration for all packages. Use the package name for a specific configuration.
        Core_Model::TYPE => [
            'form' => [
                '_mail_from_name' => 'MageWork',
                '_mail_from_email' => 'hello@example.com',
            ],
        ],
    ],
    /* ... */
];
```

```php
<?php
// etc/local.php

$config = [
    /* ... */
    'default' => [
        Core_Model::TYPE => [
            'form' => [
                '_mail_enabled' => true,
            ],
        ],
    ],
    /* ... */
];
```

This provides the contact information to the `Core_Model_Form` class.

> See Forms page for more information.


# Add a new package

- [Configuration](#configuration)
- [Tree structure](#tree-structure)
  - [packages > Acme > Page > Index.php](#packages-acme-page-index-php)
  - [packages > Acme > etc > pages.php](#packages-acme-etc-pages-php)
  - [packages > Acme > template > page.phtml](#packages-acme-template-page-phtml)
  - [packages > Acme > template > content > index.phtml](#packages-acme-template-content-index-phtml)
- [CLI page debug](#cli-page-debug)

## Configuration

Open the configuration file: `etc/local.php`

```php
<?php

$config = [
    'packages' => [
        'www.example.com' => [ // HTTP Host
            'Example' => '', // Package name (directory) and URL prefix (empty = no prefix)
            'Admin' => 'manager', // "/manager" URL path will load the "Admin" package
        ],
        'blog.example.com' => [ // HTTP Host
            'Blog' => '', // Package name (directory) and URL prefix (empty = no prefix)
        ],
    ],
    /* ... */
];
```

In this example, we use 3 packages in the same application:

- www.example.com
- www.example.com/manager
- blog.example.com

> By convention, the package name must start with a capital letter.

> If you are using a port other than 80 or 443, the port must be included in the host, e.g. : "127.0.0.1:8080".

Add the **host** for your local, the package name, and the URL prefix:

```php
<?php

$config = [
    'packages' => [
        'localhost.acme' => [ // HTTP Host
            'Acme' => '', // Package name (directory) and URL prefix (empty = no prefix)
        ],
    ],
    /* ... */
];
```

Perform the operation again for each of your environments, and change the host. The environment-specific file is `etc/local.{MW_ENVIRONMENT}.php`, for example `etc/local.prod.php`:

```php
<?php

$config = [
    'packages' => [
        'www.example.com' => [
            'Acme' => '',
        ],
    ],
    /* ... */
];
```

## Tree structure

Next, create the package directory structure:

- packages > Acme > Page > [Index.php](#packages-acme-page-index-php)
- packages > Acme > etc > [pages.php](#packages-acme-etc-pages-php)
- packages > Acme > template > [page.phtml](#packages-acme-template-page-phtml)
- packages > Acme > template > content > [index.phtml](#packages-acme-template-content-index-phtml)
- packages > Acme > template > content > error.phtml
- pub > assets > Acme > css > style.css
- pub > assets > Acme > js > app.js
- pub > assets > Acme > media > logo.png

### packages > Acme > etc > pages.php

```php
<?php

$config = [
    Core_Page::TYPE => [
        'default' => [ // Data shared for all pages
            'template' => 'page', // packages/Acme/template/page.phtml
            'language' => 'en',
        ],
        '404' => [ // Identifier loader when requested page is not found
            '_http_code' => 404,
            'content' => 'content/error', // packages/Acme/template/content/error.phtml
            'meta_title'  => 'Not Found',
            'meta_description' => 'This page doesn\'t exist',
        ],
        '/' => [ // Homepage
            'class' => Acme_Page_Index::class,
            'content' => 'content/index', // packages/Acme/template/content/index.phtml
            'meta_title' => 'Home',
            'meta_description' => 'Homepage',
        ],
    ],
];
```

### packages > Acme > Page > Index.php

```php
<?php

declare(strict_types=1);

class Acme_Page_Index extends Core_Page
{
    public function getFoo(): string
    {
        return 'Bar';
    }
}
```

A page class may define an `execute()` method, called before template rendering, to run logic and inject data into the template. Here `Acme_Page_Index` only adds a `getFoo()` helper, used by the content template.

### packages > Acme > template > page.phtml

```phtml
<!DOCTYPE html>
<html lang="<?= App::escapeHtmlAttr($this->getLanguage()) ?>">
    <head>
        <title><?= App::escapeHtml($this->getMetaTitle()) ?></title>
        <?php if ($this->getMetaDescription()): ?>
            <meta name="description" content="<?= App::escapeHtmlAttr($this->getMetaDescription()) ?>" />
        <?php endif; ?>
        <link rel="stylesheet" href="<?= $this->getAssetUrl('css/style.css') ?>" type="text/css" />
        <script type="text/javascript" src="<?= $this->getAssetUrl('js/app.js') ?>"></script>
    </head>
    <body>
        <img src="<?= $this->getAssetUrl('media/logo.png') ?>" alt="Acme" />
        <?= $this->include($this->getContent()) ?>
    </body>
</html>
```

> To display the page content, we include the content template file path.

### packages > Acme > template > content > index.phtml

```phtml
<?= App::escapeHtml($this->getFoo()) ?>
```

Access to `http://localhost.acme` in your browser!

## CLI page debug

You can display a page directly in the console, for debugging or CI/CD testing.

```
php pub/index.php {host} {path} {developer_mode} {environment}
```

Example:

```
php pub/index.php localhost.acme /
```


# Add a new page

- [Create the page](#create-the-page)
- [System Options](#system-options)
- [Request data](#request-data)
- [Handle a form submission](#handle-a-form-submission)
- [HTTP response status code](#http-response-status-code)
- [Custom headers](#custom-headers)
- [Redirection](#redirection)
- [Forward](#forward)

## Create the page

Open the `packages/Acme/etc/pages.php` file, and add a new page:

```php
<?php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/contact.html' => [
            'class' => Acme_Page_Contact::class, // Optional
            'content' => 'content/contact',
            'meta_title' => 'Contact',
            'meta_description' => 'Contact our team',
            'telephone' => '+33 610506070',
        ],
        /* ... */
    ],
];
```

The configuration key **is** the route (with its leading slash) — there is no separate page name. `class` is optional: without it the page falls back to `Acme_Page`, then `Core_Page` (see Objects and class fallback).

**Warning:**

- For a route with an extension, the **first slash** is required: `/slug.html`
- For a route without an extension, the **first and last slashes** are required: `/slug/`

Create a new class: `packages/Acme/Page/Contact.php`

```php
<?php

declare(strict_types=1);

class Acme_Page_Contact extends Core_Page
{
    public function execute(): void
    {
        $this->setAddress("459 Walker Cape, Powellchester, OL16 3NA");
    }

    public function getEmail(): string
    {
        return 'john.doe@example.com';
    }
}
```

> The **execute** method is called before template rendering. It allows you to implement the code logic and inject data into the template.

Finally, create the template file: `packages/Acme/template/content/contact.phtml`

```phtml
<h2>Contact us!</h2>

<p>Telephone: <?= App::escapeHtml($this->getTelephone()) ?></p>
<p>Address: <?= App::escapeHtml($this->getAddress()) ?></p>
<p>Email: <?= App::escapeHtml($this->getEmail()) ?></p>
```

Access to `/contact.html` in your browser!

## System Options

| Option | Description | Type |
| --- | --- | --- |
| class | The page class with custom logic and methods | string |
| _http_code | [HTTP response status code](#http-response-status-code) | integer |
| _headers | [Custom headers](#custom-headers) | array |

## Request data

Inside a page class, `dataPost()` and `dataGet()` return a `DataObject` built from `$_POST` and `$_GET`:

```php
<?php

declare(strict_types=1);

class Acme_Page_Search extends Core_Page
{
    public function execute(): void
    {
        $query = $this->dataGet()->getData('q');           // ?q=...
        $page  = (int)($this->dataGet()->getData('page') ?: 1);

        $this->setData('results', App::getSingleton('catalog', Core_Model::TYPE)->search($query, $page));
    }
}
```

```php
<?php

$firstname = $this->dataPost()->getData('firstname');
$all = $this->dataPost()->getData(); // every posted field
```

## Handle a form submission

A form posts to its own route. Declare that route like any other page and point it at a handler class:

`packages/Acme/etc/pages.php`

```php
<?php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/contact/post/' => [ // route without extension: first *and* last slash
            'class' => Acme_Page_Contact_Post::class,
            'template' => null,
        ],
        /* ... */
    ],
];
```

`packages/Acme/Page/Contact/Post.php`

```php
<?php

declare(strict_types=1);

class Acme_Page_Contact_Post extends Core_Page
{
    public function execute(): void
    {
        $post = $this->dataPost();

        // ... validate the fields, send the mail, store a flash message ...

        $this->setSuccessMessage('Your message has been sent.');
        $this->redirect('contact.html');
    }
}
```

`redirect()` ends the request, so a POST handler never renders a template. See Forms for field validation and mailing, Captcha for the anti-bot check, and Session messages to display the result after the redirect.

## HTTP response status code

You can force the page HTTP response code with the **_http_code** parameter:

```php
<?php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/teapot.html' => [
            '_http_code' => 418,
            'class' => Acme_Page_Teapot::class,
            'content' => 'content/teapot',
            'meta_title' => 'Teapot',
        ],
        /* ... */
    ],
];
```

Or in the page class:

```php
<?php

declare(strict_types=1);

class Acme_Page_Teapot extends Core_Page
{
    public function execute(): void
    {
        $this->setData('_http_code', 418);
    }
}
```

## Custom headers

The **_headers** parameter allows you to send custom headers:

```php
<?php

$config = [
    Core_Page::TYPE => [
        /* ... */
        'default' => [
            '_headers' => [
                'Content-Security-Policy' => 'default-src \'self\'',
                'X-Frame-Options' => 'DENY',
                'X-XSS-Protection' =>  '1; mode=block',
                'X-UA-Compatible' => 'IE=Edge',
                'X-Content-Type-Options' => 'nosniff',
            ],
        ],
        /* ... */
    ],
];
```

Or in the page class:

```php
<?php

declare(strict_types=1);

class Acme_Page_Teapot extends Core_Page
{
    public function execute(): void
    {
        $this->setData(
            '_headers',
            array_merge(
                $this->getData('_headers') ?: [],
                ['X-MageWork' => 1]
            )
        );
    }
}
```

## Redirection

A static redirect can be declared in the configuration:

```php
<?php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/old-page.html' => [
            '_http_code' => 301,
            '_headers' => [
                'location' => '/new-page.html',
            ],
        ],
        /* ... */
    ],
];
```

From a page class, use `redirect()`. A path is turned into a full URL with `getUrl()`; an absolute URL is used as-is; no argument redirects to the home page.

```php
<?php

public function execute(): void
{
    if (!App::session()?->has('customer_id')) {
        $this->redirect('login.html');
    }
}
```

## Forward

`forward()` renders another route in place of the current one, without changing the URL
or sending a redirect:

```php
<?php

public function execute(): void
{
    if (!$this->isAllowed()) {
        $this->forward('/403.html');
    }
}
```


# Templating best practices

## Type hinting

Add the type hinting using the `@var` tag at the beginning of the template file.

```phtml
<?php /** @var Core_Page $this */ ?>
```

```phtml
<?php /** @var Core_Block $this */ ?>
```

## URL

Always use the `getUrl` method to build internal links, with the link path as an argument. This secures the link.

> Pass the route path **without the leading slash** used in the route declaration: `getUrl('contact.html')` targets the route declared as `'/contact.html'`, and `getUrl('contact/post/')` targets `'/contact/post/'`.

```phtml
<a href="<?= $this->getUrl('page.html') ?>">My Page</a>
```

```phtml
<a href="<?= $this->getUrl('page.html') ?>#contact">My Page</a>
```

```phtml
<a href="<?= $this->getUrl('download/invoice', ['id' => 1]) ?>">Download</a>
```

```phtml
<a href="<?= $this->getUrl('customer.html', ['id' => 1]) ?>">My Account</a>
```

## Escape

Always escape variables from internal methods.

- `App::escapeHtml` : escape content that will be rendered within HTML tags
- `App::escapeHtmlAttr` : escape data that will be placed within HTML element attributes
- `App::escaper()->escapeQuotes` : escape single quotation marks in a string by prefixing them with a backslash

```phtml
<p><?= App::escapeHtml($this->getWelcomeText()) ?></p>
```

```phtml
<p class="<?= App::escapeHtmlAttr($this->getClassName()) ?>">My Text</p>
```

```phtml
<a href="#" onclick="alert('<?= App::escaper()->escapeQuotes("that's true") ?>')">Alert</a>
```


# Add a new block

Open the `packages/Acme/etc/blocks.php` file, and add a new block:

```php
<?php

$config = [
    Core_Block::TYPE => [
        /* ... */
        'banner' => [ // The block identifier
            'class' => Acme_Block_Banner::class, // Optional
            'caption' => 'Welcome to MageWork!',
        ],
        /* ... */
    ],
];
```

Create a new class: `packages/Acme/Block/Banner.php`

```php
<?php

declare(strict_types=1);

class Acme_Block_Banner extends Core_Block
{
    public function execute(): void
    {
        $this->setImage('media/banner.png');
    }

    public function canShow(): bool
    {
        return true;
    }
}
```

> The **execute** method is called before template rendering. It allows you to implement the code logic and inject data into the template.

Open any template file, example: `packages/Acme/template/page.phtml`

```phtml
<?= $this->getBlock('block/banner', ['alt' => 'My Banner'], 'banner') ?>
```

> The first argument is the block template file in "packages/Acme/template" (required).
> The second argument is the data to send to the block (optional).
> The last argument is the block identifier defined in the configuration file (optional).

Use `keep()` to forward only part of the current object's data to the block:

```phtml
<?= $this->getBlock('block/head', $this->keep(['meta_title', 'meta_description']), 'head') ?>
```

Finally, create the template file: `packages/Acme/template/block/banner.phtml`

```phtml
<?php /** @var Acme_Block_Banner $this */ ?>
<?php if ($this->canShow()): ?>
<figure>
    <img src="<?= $this->getAssetUrl($this->getImage()) ?>" alt="<?= App::escapeHtmlAttr($this->getAlt()) ?>" />
    <figcaption><?= App::escapeHtml($this->getCaption()) ?></figcaption>
</figure>
<?php endif; ?>
```


# Assets

- [Asset directory](#asset-directory)
- [Asset URL](#asset-url)
- [Query parameters](#query-parameters)
- [Asset of another package](#asset-of-another-package)
- [Asset file path](#asset-file-path)

## Asset directory

Public files (CSS, JS, images, fonts, `robots.txt`, `favicon.ico`...) live in the package asset directory:

```
pub > assets > Acme > css > style.css
pub > assets > Acme > js > app.js
pub > assets > Acme > media > logo.png
```

Any file placed there is served directly by MageWork, with an automatic `Content-Type` (requires `ext-fileinfo`). A file whose name starts with a dot is never served.

## Asset URL

In a template, build the URL with `getAssetUrl()`. The path is relative to the current package asset directory:

```phtml
<link rel="stylesheet" href="<?= $this->getAssetUrl('css/style.css') ?>" type="text/css" />
<img src="<?= $this->getAssetUrl('media/logo.png') ?>" alt="Acme" />
```

## Query parameters

The second argument adds query parameters, for example to bust the browser cache:

```phtml
<link rel="stylesheet" href="<?= $this->getAssetUrl('css/style.css', ['v' => '2.1.0']) ?>" type="text/css" />
```

## Asset of another package

The third argument loads an asset from another package:

```phtml
<img src="<?= $this->getAssetUrl('media/logo.png', [], 'Admin') ?>" alt="Admin" />
```

## Asset file path

`App::getAssetPath()` returns the absolute file path of an asset (not a URL), useful to
read a file on disk:

```php
<?php

$size = filesize(App::getAssetPath('media/logo.png'));
```


# Serve any type of file

- [Page](#page)
- [Examples](#examples)
- [sitemap.xml](#sitemap-xml)
- [robots.txt](#robots-txt)

## Page

In the page configuration file, add a new page with the **class name**, and the **Content-Type** if needed:

```php
<?php
// packages/Acme/etc/page.php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/api/customers/' => [
            'class' => Acme_Page_Api_Customer::class,
            '_headers' => [
                'Content-Type' => 'application/json',
            ],
        ],
        /* ... */
    ],
];
```

In the class, cancel any potential template, then render the desired content:

```php
<?php
// packages/Acme/Page/Api/Customer.php

declare(strict_types=1);

class Acme_Page_Api_Customer extends Core_Page
{
    public function execute(): void
    {
        $this->setTemplate(null);
    }

    public function render(): string
    {
        return json_encode(
            [
                'customers' => [
                    [
                        'identifier' => 1,
                        'name' => 'John Doe',
                    ],
                ],
            ]
        );
    }
}
```

## Examples

### sitemap.xml

```php
<?php
// packages/Acme/etc/page.php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/sitemap.xml' => [
            'class' => Acme_Page_Sitemap::class,
            '_headers' => [
                'Content-Type' => 'application/xml',
            ],
        ],
        /* ... */
    ],
];
```

```php
<?php
// packages/Acme/Page/Sitemap.php

declare(strict_types=1);

class Acme_Page_Sitemap extends Core_Page
{
    public function execute(): void
    {
        $this->setTemplate(null);
    }

    public function render(): string
    {
        $pages = App::db()->getAll('pages');

        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
        foreach ($pages as $page) {
            $xml .= '<url><loc>' . $this->getUrl($page['slug']) . '</loc></url>' . "\n";
        }
        $xml .= '</urlset>';

        return $xml;
    }
}
```

### robots.txt

```php
<?php
// packages/Acme/etc/page.php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/robots.txt' => [
            'class' => Acme_Page_Robots::class,
            '_headers' => [
                'Content-Type' => 'text/plain',
            ],
        ],
        /* ... */
    ],
];
```

```php
<?php
// packages/Acme/Page/Robots.php

declare(strict_types=1);

class Acme_Page_Robots extends Core_Page
{
    public function execute(): void
    {
        $this->setTemplate(null);
    }

    public function render(): string
    {
        $robots = 'User-agent: *' . "\n";

        if (App::getEnvironment() === 'default') {
            $robots .= 'Allow: /' . "\n";
        } else {
            $robots .= 'Disallow: /' . "\n";
        }

        return $robots;
    }
}
```


# Rewrite

To rewrite a route, add a model rewrite class in your package. If the package is named `Acme`, the class will be `Acme_Model_Rewrite`.

Create the `packages/Acme/Model/Rewrite.php` file.

```php
<?php

declare(strict_types=1);

class Acme_Model_Rewrite extends Core_Model
{
    public function execute(): void
    {
        $route = App::getRoute(); // The current route without query parameters and the final /

        // /my-page.html = /my-page.html
        // /my-page/     = /my-page
        // /my-page/?q=1 = /my-page
        // /my-page?q=1  = /my-page
    }
}
```

Then, write a regular expression to check if the route matches what you want.

If the route matches, you can update the current route and add the parameters.

```php
<?php

declare(strict_types=1);

class Acme_Model_Rewrite extends Core_Model
{
    public function execute(): void
    {
        $route = App::getRoute();

        if (preg_match('/^\/customer\/(?P<id>[0-9]*)$/', $route, $matches)) { // /customer/{id}
            $this->setData('route', '/customer/');
            $_GET['id'] = $matches['id'];
        }
    }
}
```

In this example, the route `/customer/?id=10` can be rewritten in `/customer/10`.


# Data assignment

You can pass data to any object or template with the configuration file: `packages/Acme/etc/config.php`

```php
<?php

$config = [
    Core_Block::TYPE => [
        'default' => [
            'scope' => 'I\'m available in all blocks of the "Acme" package',
        ],
        'banner' => [
            'scope' => 'I\'m available in the block "banner" of the "Acme" package',
        ],
    ],
];
```

The data will be available in a class or template file with the `getData` method or the magic getter:

```php
<?php

$this->getData('scope');
// or
$this->getScope();
```

To set data at runtime (from `execute()`, for example) use `setData()` / `addData()`, and `keep()` to forward part of it to a block — see DataObject.

Use global configuration file to set data to packages and objects: `etc/config.php`

```php
<?php

$config = [
    'default' => [ // All packages
        'default' => [
            'default' => [
                'scope' => 'I\'m available everywhere in all packages for the current environment',
            ],
        ],
        Core_Page::TYPE => [
            'default' => [
                'scope' => 'I\'m available in all pages of all packages for the current environment',
            ],
        ],
        Core_Block::TYPE => [
            'banner' => [
                'scope' => 'I\'m available in the block "banner" of all packages for the current environment',
            ],
        ],
    ],
];
```

> A "default" value will be overridden by the specified package, type or identifier.

**Overload hierarchy:**

1. default.default.default
2. default.default.{identifier}
3. default.{type}.default
4. default.{type}.{identifier}
5. {package}.default.default
6. {package}.default.{identifier}
7. {package}.{type}.default
8. {package}.{type}.{identifier}


# Objects and class fallback

- [Custom classes](#custom-classes)
- [Class fallback priorities](#class-fallback-priorities)
- [The execute() method](#the-execute-method)
- [DataObject](#dataobject)
- [Overrides](#overrides)
- [Fallback examples](#fallback-examples)

## Custom classes

You can use a custom class for any types, by specifying the wanted class name.

```php
<?php
// packages/Acme/etc/config.php

$config = [
    Core_Page::TYPE => [ // Type
        '/contact.html' => [ // Identifier (a page identifier is its route)
            'class' => Acme_Page_Contact::class,
        ],
    ],
    Core_Block::TYPE => [ // Type
        'banner' => [ // Identifier
            'class' => Acme_Block_Banner::class,
        ],
    ],
    Core_Model::TYPE => [ // Type
        'customer' => [ // Identifier
            'class' => Acme_Model_Customer::class,
        ],
    ],
];
```

To create an instance of an object, you need to use the `App::getSingleton` or `App::getObject` methods:

`App::getSingleton({identifier}, {type}, {package})` (Create a single instance)

`App::getObject({identifier}, {type}, {package})` (Create a new instance)

```php
<?php

/** @var Acme_Model_Customer $model */
$model = App::getSingleton('customer', Core_Model::TYPE);
// packages/Acme/Model/Customer.php

/** @var Admin_Model_Customer $model */
$model = App::getSingleton('customer', Core_Model::TYPE, 'admin');
// packages/Admin/Model/Customer.php
```

## Class fallback priorities

If the class is missing, the system will automatically attempt to load a class based on the following priorities:

1. `{package}_{type}_{identifier}`
2. `Core_{type}_{identifier}`
3. `{package}_{type}`
4. `Core_{type}`
5. `DataObject`

## The execute() method

When the factory builds an object (page, block, model, mail, console command, rewrite), it calls its `execute()` method — if defined — immediately after instantiation and after the configuration data has been injected, but **before** the template is rendered.

It is the single entry point for your logic: read the request, call a model, inject data into the template.

```php
<?php

declare(strict_types=1);

class Acme_Page_Contact extends Core_Page
{
    public function execute(): void
    {
        $this->setData('countries', App::getSingleton('country', Core_Model::TYPE)->getAll());
    }
}
```

## DataObject

Every object extends `DataObject`, a simple typed container:

```php
<?php

$this->setData('title', 'Hello');        // set one key
$this->addData(['a' => 1, 'b' => 2]);    // set several keys
$this->getData('title');                 // read one key
$this->getData();                        // read every key (array)
$this->getTitle();                       // magic getter  -> getData('title')
$this->setTitle('Hi');                   // magic setter  -> setData('title', 'Hi')
$this->hasData('title');                 // key exists?
$this->unsetData('title');               // remove one key
$this->keep(['a', 'b']);                 // new array with only the given existing keys
$this->toArray();                        // every key as an array
```

`keep()` is handy to forward only part of a page's data to a block:

```phtml
<?= $this->getBlock('block/head', $this->keep(['meta_title', 'meta_description']), 'head') ?>
```

## Overrides

To override default **core objects** like `Core_Block` or `Core_Page`, add the class to the root of your package:

`packages > Acme > Page.php`

```php
<?php

declare(strict_types=1);

class Acme_Page extends Core_Page
{
    public function myCustomMethod(): string
    {
        return 'Hello World!';
    }
}
```

Your class methods will now be available for all pages.

> Don't forget to inherit your **own classes** from `Acme_Page` instead of `Core_Page`.

In the same way for the blocks:

`packages > Acme > Block.php`

```php
<?php

declare(strict_types=1);

class Acme_Block extends Core_Block
{
    public function myCustomMethod(): string
    {
        return 'Hello World!';
    }
}
```

Your class methods will now be available for all blocks.

## Fallback examples

For a **block** type, with **banner** identifier, in the **Acme** package.

`App::getSingleton('banner', Core_Block::TYPE)`

The system will try to load classes in this order until it is found:

1. Acme_Block_Banner
2. Core_Block_Banner
3. Acme_Block
4. Core_Block
5. DataObject

For a **model** type, with **customer** identifier, in the **Acme** package.

`App::getSingleton('customer', Core_Model::TYPE)`

The system will try to load classes in this order until it is found:

1. Acme_Model_Customer
2. Core_Model_Customer
3. Acme_Model
4. Core_Model
5. DataObject


# Models

A **model** (`Core_Model`, type `model`) is the place for your business logic: database access, computations, third-party calls, anything that is not display.

- [Create a model](#create-a-model)
- [Use a model](#use-a-model)
- [The execute() method](#the-execute-method)

## Create a model

Create a class in the `Model` directory of your package:

`packages/Acme/Model/Customer.php`

```php
<?php

declare(strict_types=1);

class Acme_Model_Customer extends Core_Model
{
    public function getActive(): array
    {
        return App::db()
            ->where(['is_active =' => 1])
            ->getAll('customers');
    }
}
```

`Core_Model` extends `DataObject`, so a model also has `getData()` / `setData()` and the magic getters and setters (see Data assignment).

## Use a model

Instantiate the model with `App::getSingleton()` (shared instance) or `App::getObject()` (new instance) — see Objects and class fallback.

```php
<?php

/** @var Acme_Model_Customer $customer */
$customer = App::getSingleton('customer', Core_Model::TYPE);

$rows = $customer->getActive();
```

From another package, pass the package name as the third argument:

```php
<?php

$customer = App::getSingleton('customer', Core_Model::TYPE, 'admin'); // Admin_Model_Customer
```

## The execute() method

If a model (like any object built by the factory) defines an `execute()` method, it is called automatically right after instantiation, before you use the object. See Objects and class fallback.

```php
<?php

declare(strict_types=1);

class Acme_Model_Cart extends Core_Model
{
    public function execute(): void
    {
        $this->setData('items', App::session()?->get('cart') ?: []);
    }
}
```


# Database

- [Insert](#insert)
- [Update](#update)
- [Delete](#delete)
- [Get All](#get-all)
- [Get Row](#get-row)
- [Get Value](#get-value)
- [Debug & Dump](#debug-dump)
- [Multiple connections](#multiple-connections)

## Insert

```php
<?php

$id = App::db()
    ->insert('customers', ['email' => 'john@example.com', 'firstname' => 'John', 'lastname' => 'Doe']);

// INSERT INTO customers (email,firstname,lastname) VALUES ('john@example.com','John','Doe')
```

```php
<?php

$id = App::db()
    ->insert(
        'customers',
        ['email' => 'john@example.com', 'attempts' => 1],
        ['attempts = attempts + 1']
    );

// INSERT INTO customers (email) VALUES ('john@example.com') ON DUPLICATE KEY UPDATE attempts = attempts + 1
```

## Update

```php
<?php

$count = App::db()
    ->where(['email =' => 'john@example.com'])
    ->update('customers', ['firstname' => 'Jane', 'lastname' => null]);

// UPDATE customers SET firstname = 'Jane', lastname = NULL WHERE (email = 'john@example.com')
```

```php
<?php

$count = App::db()
    ->where(['email =' => 'john@example.com'])
    ->update('customers', ['failures_num = failures_num + 1', 'lastname = upper(lastname)']);

// UPDATE customers SET failures_num = failures_num + 1, lastname = upper(lastname) WHERE (email = 'john@example.com')
```

## Delete

```php
<?php

App::db()->where(['email =' => 'john@example.com'])->delete('customers');

// DELETE FROM customers WHERE (email = 'john@example.com')
```

## Get All

```php
<?php

$customers = App::db()
    ->getAll('customers');

// SELECT * FROM customers
```

```php
<?php

$customers = App::db()
    ->groupBy('email')
    ->orderBy('firstname', 'ASC')
    ->page(3)
    ->limit(20)
    ->getAll('customers', ['firstname', 'lastname', 'email']);

// SELECT firstname,lastname,email FROM customers GROUP BY email ORDER BY firstname ASC LIMIT 40,20
```

```php
<?php

$customers = App::db()
    ->leftJoins([
        'orders o' => ['o.customer_id = c.id'],
        'invoices i' => ['i.order_id = o.id'],
    ])
    ->getAll('customers c');

// SELECT * FROM customers c LEFT JOIN orders o ON (o.customer_id = c.id) LEFT JOIN invoices i ON (i.order_id = o.id)
```

```php
<?php

$customers = App::db()
    ->where([
        [
            'email =' => 'john@example.com',
            // OR
            'lastname =' => 'Doe'
        ],
        // AND
        ['is_active =' => 1],
        // AND
        ['`group` IN' => ['general', 'gold']]
    ])
    ->getAll('customers');

// SELECT * FROM customers WHERE (email = 'john@example.com' OR lastname = 'Doe') AND (is_active = '1') AND (`group` IN ('general','gold'))
```

## Get Row

```php
<?php

$customer = App::db()
    ->where(['email =' => 'john@example.com'])
    ->getRow('customers');

// SELECT * FROM customers WHERE (email = 'john@example.com') LIMIT 0,1
```

## Get Value

```php
<?php

$customerId = App::db()
    ->where(['email =' => 'john@example.com'])
    ->getVal('customers', ['id']);

// SELECT id FROM customers WHERE (email = 'john@example.com') LIMIT 0,1
```

## Debug & Dump

```php
<?php

App::db()->debug(true); // Do not execute all the next requests

App::db()
    ->where(['email =' => 'john@example.com'])
    ->update('customers', ['firstname' => 'Jane']);

var_dump(App::db()->dump()); // Retrieve last query and params

App::db()->debug(false); // Disabled the debug mode
```

## Multiple connections

`App::db()` uses the `database` model. To use a second database, create a model that
extends `Core_Model_Database`:

`packages/Acme/Model/Reporting.php`

```php
<?php

declare(strict_types=1);

class Acme_Model_Reporting extends Core_Model_Database
{
}
```

Configure its credentials under the model identifier:

```php
<?php
// etc/local.php

$config = [
    /* ... */
    'default' => [
        Core_Model::TYPE => [
            'reporting' => [
                'db_host' => '',
                'db_username' => '',
                'db_password' => '',
                'db_database' => '',
            ],
        ],
    ],
    /* ... */
];
```

Then pass the model identifier to `App::db()`:

```php
<?php

$rows = App::db('reporting')->getAll('events');
```



# Forms

`Core_Model_Form` collects, validates and (optionally) emails a submitted form. See also Session messages to give feedback after the redirect and Send emails for the message body.

```php
<?php

/** @var Core_Model_Form $form */
$form = App::getSingleton('form', Core_Model::TYPE);

if ($form->isPost()) {
    $form->setFormFields( // Fields to retrieve on error
            [
                'firstname' => 'Firstname',
                'customer[firstname]' => 'Customer firstname',
                'customer[address][0]' => 'Customer address line 1',
                'email' => 'E-mail',
                'country' => 'Country',
            ]
        )
        ->setFormSpamField('subject') // Error if this (hidden) field is filled
        ->setFormRequiredFields( // All required fields
            [
                'firstname',
                'customer[firstname]',
                'customer[address][0]',
            ]
        )
        ->setFormExceptedValues( // Field to check
            [
                'firstname' => '/[^0-9]/', // string = preg_match
                'email' => FILTER_VALIDATE_EMAIL, // int = filter_var
                'country' => ['fr', 'es', 'us'], // array = in_array
            ]
        )
        ->validate();

    if (!$form->getError()) {
        /* ... */
        $form->getFirstname(); // name="firstname"
        $form->getCustomerFirstname(); // name="customer[firstname]"
        $form->getData('customer_address_0'); // name="customer[address][0]"

        /** @var Core_Mail $message */
        $message = App::getSingleton('contact', Core_Mail::TYPE); // Acme_Mail_Contact

        $form->setMailSubject('New contact message!');
        $form->setMailMessage($message->render());
        $form->setMailSendTo('contact@example.com');
        $form->sendMail();
    } else {
        $form->getErrorFieldName();
        $form->getErrorFieldLabel();
    }
}
```


# Session messages

A page can store a **success** or **error** message in the session, typically before a redirect, and display it on the next request. The message is removed as soon as it is read (flash message).

- [Set a message](#set-a-message)
- [Display the message](#display-the-message)

## Set a message

From a page class:

```php
<?php

declare(strict_types=1);

class Acme_Page_Contact_Post extends Core_Page
{
    public function execute(): void
    {
        // ... process the form ...

        $this->setSuccessMessage('Your message has been sent.');
        $this->redirect('contact.html');
    }
}
```

| Method | Description |
| --- | --- |
| setSuccessMessage | Store a success message in the session |
| setErrorMessage | Store an error message in the session |
| getSuccessMessage | Return and clear the success message |
| getErrorMessage | Return and clear the error message |

## Display the message

Render the `message` block at the top of your page template, passing the current messages. The `getSuccessMessage()` / `getErrorMessage()` calls read the session without starting a new one when there is nothing to show.

`packages/Acme/template/page.phtml`

```phtml
<main>
    <div id="main-content">
        <?= $this->getBlock('block/message', ['success' => $this->getSuccessMessage(), 'error' => $this->getErrorMessage()]) ?>
        <?= $this->include($this->getContent()) ?>
    </div>
</main>
```

`packages/Acme/template/block/message.phtml`

```phtml
<?php /** @var Core_Block $this */ ?>
<?php if ($this->getSuccess()): ?>
    <p class="message success"><?= App::escapeHtml($this->getSuccess()) ?></p>
<?php endif; ?>
<?php if ($this->getError()): ?>
    <p class="message error"><?= App::escapeHtml($this->getError()) ?></p>
<?php endif; ?>
```


# Send emails

Emails are built from a template, exactly like a page or a block. A mail object (`Core_Mail`, type `mail`) renders an HTML template that you then send.

- [Mail template](#mail-template)
- [Render and send](#render-and-send)
- [With a form](#with-a-form)

## Mail template

Declare the mail in the block/object configuration and point it to a template:

`packages/Acme/etc/config.php`

```php
<?php

$config = [
    Core_Mail::TYPE => [
        'contact' => [
            'template' => 'mail/contact',
        ],
    ],
];
```

Create the template file:

`packages/Acme/template/mail/contact.phtml`

```phtml
<?php /** @var Core_Mail $this */ ?>
<h1>New contact message</h1>

<p>From: <?= App::escapeHtml($this->getName()) ?> (<?= App::escapeHtml($this->getEmail()) ?>)</p>
<p><?= nl2br(App::escapeHtml($this->getMessage())) ?></p>
```

You may also create a class `packages/Acme/Mail/Contact.php` (`Acme_Mail_Contact extends Core_Mail`) if the mail needs its own logic.

## Render and send

```php
<?php

/** @var Core_Mail $mail */
$mail = App::getSingleton('contact', Core_Mail::TYPE);
$mail->addData([
    'name'    => 'John Doe',
    'email'   => 'john@example.com',
    'message' => "Hello,\nI have a question.",
]);

$html = $mail->render();

// Send it with your preferred transport, or with the native mail() function
mail('contact@example.com', 'New contact message', $html, "MIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n");
```

## With a form

`Core_Model_Form` sends the email for you when contact settings are enabled (see Configuration and Forms). Pass the rendered mail template as the message body:

```php
<?php

/** @var Core_Mail $message */
$message = App::getSingleton('contact', Core_Mail::TYPE);
$message->addData($form->getData());

$form->setMailSubject('New contact message!');
$form->setMailMessage($message->render());
$form->setMailSendTo('contact@example.com');
$form->sendMail();
```

If `setMailMessage()` is not called, the form builds a default message from the posted fields and their labels.


# Captcha

- [Display the PNG captcha image](#display-the-png-captcha-image)
- [Validate the captcha](#validate-the-captcha)
- [Captcha customization](#captcha-customization)

## Display the PNG captcha image

On the page class containing the form, add a method to generate a captcha and store the text in a session variable:

```php
<?php

declare(strict_types=1);

class Acme_Page_Contact extends Core_Page
{
    public function getCaptcha(): string
    {
        $captcha = new Captcha();

        App::session()->set('captcha', $captcha->getText());

        return $captcha->inline();
    }
}
```

Display the captcha image in the form:

```phtml
<form action="<?= $this->getUrl('contact/post/') ?>" method="post">
    <img src="<?= $this->getCaptcha() ?>" alt="This is a textual captcha" />
    <input type="text" name="secure" value="" />

    <input type="submit" value="Submit" />
</form>
```

## Validate the captcha

When the form is submitted, check the captcha's validity:

```php
<?php

declare(strict_types=1);

class Acme_Page_Contact_Post extends Core_Page
{
    public function execute(): void
    {
        $dataPost = $this->dataPost();

        $captcha = App::session()->pull('captcha');

        if ($dataPost->getData('secure') !== $captcha) {
            $this->setErrorMessage('Captcha is not correct');
            $this->redirect('contact.html');
        }

        // Form Processing
    }
}
```

## Captcha customization

| Method | Description | Example |
| --- | --- | --- |
| setFont | Absolute monospace font file path in ttf format | /var/www/magework/fonts/courier.ttf |
| setColor | Text color in hexadecimal | #ffffff |
| setBackground | Background color in hexadecimal | #000000 |
| setChars | Allowed characters (included in the font) | abcdefghijkmnopqrstuvwxyz23456789 |
| setLength | Text length | 5 |
| setWidth | Image width in px | 105 |
| setHeight | Image height in px | 40 |
| setFontSize | Font size | 20 |
| setPaddingTop | Image padding top in px | 28 |
| setPaddingLeft | Image padding left in px | 12 |
| setAngle | Text orientation angle | 5 |

```php
<?php

$captcha = new Captcha();
$captcha->setColor('#ffffff')->setBackground('#000000');
```


# Framework tools

- [Page](#page)
- [Encryption](#encryption)
- [Cache](#cache)
- [Session](#session)
- [Log](#log)
- [Database](#database)
- [Config](#config)
- [Escaper](#escaper)
- [Search](#search)

## Page

```php
<?php

$page = App::page();
```

The `page` method allows retrieving the current page object from anywhere.

```php
<?php

$route = App::getRoute();
```

The `getRoute` method allows retrieving the current (cleaned) route name from anywhere (e.g. `/contact.html`).

```
/my-page.html = /my-page.html
/my-page/     = /my-page
/my-page/?q=1 = /my-page
/my-page?q=1  = /my-page
```

## Encryption

```php
<?php

$var = App::encryption()->crypt('message');

echo App::encryption()->decrypt($var);
```

The encryption key is stored in `var/encryption` (read-only `0400` file). If the key is lost, it will be impossible to decrypt the data ever again.

Pass `true` as the second argument to use a fixed initialization vector (same input always produces the same output). Only do this for values that are guaranteed unique.

```php
<?php

$token = App::encryption()->crypt($uuid, true);
```

## Cache

```php
<?php

App::cache()->set('foo', 'bar');

echo App::cache()->get('foo');
```

The cache file is stored in `var/cache`. The default cache lifetime is **86400** seconds. Update it before assignment:

```php
<?php

App::cache()->setLifetime(3600)->set('foo', 'bar');
```

| Method | Description |
| --- | --- |
| set | Store one value |
| bulk | Store several values at once (array) |
| get | Read one value (`null` if missing or expired) |
| all | Read every cached value |
| setLifetime / getLifetime | Lifetime in seconds (`0` disables caching) |
| cleanByKey | Remove one key if it is expired |
| cleanExpired | Remove every expired key |
| cleanAll | Empty the cache |

## Session

```php
<?php

App::session()?->set('foo', 'bar');

echo App::session()?->get('foo');
```

Sessions are stored in `var/session`. The session cookie is named after the package and its lifetime, `SameSite` and `HttpOnly` flags come from the configuration (see Configuration).

| Method | Description |
| --- | --- |
| set | Store one value, or several with an array |
| get | Read one value (all values with no argument) |
| pull | Read a value **and remove it** |
| has | Check a value exists |
| id / regenerate | Current session id / regenerate it |
| destroy | Destroy the session and clear the cookie |

`App::session(false)` returns the session only if one already exists, without starting a new one.

## Log

```php
<?php

App::log('message');
```

Logs are stored in `var/log/app.log`. A `Throwable`, an array or an object is formatted automatically.

The second parameter sets the level (default `Logger::INFO`):

```php
<?php

App::log('message', Logger::WARN);

Logger::EMERG;  // Emergency: system is unusable
Logger::ALERT;  // Alert: action must be taken immediately
Logger::CRIT;   // Critical: critical conditions
Logger::ERR;    // Error: error conditions
Logger::WARN;   // Warning: warning conditions
Logger::NOTICE; // Notice: normal but significant condition
Logger::INFO;   // Informational: informational messages
Logger::DEBUG;  // Debug: debug messages
```

`App::getLogger()` returns the `Logger` instance. Use `trim()` to cap the file size:

```php
<?php

App::getLogger()?->trim(1000); // keep only the last 1000 lines
```

## Database

```php
<?php

$id = App::db()->insert('table', ['name' => 'John Doe']);

App::db()->query('UPDATE table SET name = ? WHERE id = ?', ["Jane Doe", 1]);

$result = App::db()->query('SELECT * FROM table')->fetchAll();

$result = App::db()->query('SELECT * FROM table WHERE id = ?', [1])->fetch();
```

## Config

```php
<?php

echo App::getConfig('app.session_lifetime');
```

The second argument is the value returned when the path does not exist:

```php
<?php

$lifetime = App::getConfig('app.session_lifetime', 3600);
```

## Escaper

```php
<?php

App::escapeHtml('<a href="#">Escaped HTML</a>');

App::escapeHtmlAttr('my-class');

App::escapeUrl('https://www.example.com');

App::escaper()->escapeQuotes("that's true");
```

```phtml
<a href="#" onclick="alert('<?= App::escaper()->escapeQuotes("that's true") ?>')">Alert</a>
```

## Search

```php
<?php

$indexes = [
    1 => 'Phillip J. Fry',
    2 => 'Leela Turanga',
    3 => 'Bender Bending Rodriguez',
    4 => 'Capitaine Zapp Brannigan',
    5 => 'Amy Wong',
];

$search = new Search();

$result = $search->search('leilla captain', $indexes);
```

```
array(2) {
    [2]=> string(13) "Leela Turanga"
    [4]=> string(24) "Capitaine Zapp Brannigan"
}
```

The constructor tunes the matching: `new Search($minChar, $minDistance)` — minimum word length (default `3`) and maximum Levenshtein distance ratio for a fuzzy match (default `0.4`, lower is stricter).

```php
<?php

$search = new Search(3, 0.2); // stricter matching
```


# Console Commands

```
bin/magework {package} {identifier} {args...}
```

With optional environment variable:

```
MW_ENVIRONMENT={environment} bin/magework {package} {identifier} {args...}
```

To add a command to execute via CLI, add a new class in the `Console` folder of your package.

Create a new class: `packages/Acme/Console/Date.php`

```php
<?php

declare(strict_types=1);

class Acme_Console_Date implements Core_Console_Interface
{
    public function run(array $args): int
    {
        $format = $args[0] ?? 'Y-m-d H:i:s';

        echo date($format) . "\n";

        return self::SUCCESS;
    }
}
```

`run()` returns the process exit code: `self::SUCCESS` (`0`) or `self::ERROR` (`1`).

Run the script via the command line:

```
bin/magework Acme date "d/m/Y H:i"
```

The identifier contains underscore for deep classes.

The class `Acme_Console_Customer_Clean` in `packages/Acme/Console/Customer/Clean.php` will be executed with:

```
bin/magework Acme customer_clean
```


# Hooks

The hooks system allows you to modify or extend the application behavior without altering existing source code. A hook is an anchor point where custom processing can be inserted.

- [Native hooks](#native-hooks)
- [Creating a hook](#creating-a-hook)
- [Triggering a hook](#triggering-a-hook)

## Native hooks

MageWork triggers the following hooks during a request. The `$data` array passed to `process()` contains the keys listed below; modifying them changes what the framework does next.

| Hook | Passed data | Purpose |
| --- | --- | --- |
| `app.page.init` | `identifier` | Change the resolved page identifier (route) before it is loaded |
| `app.asset.read` | `file` | Change the static asset file about to be sent |
| `app.page.render` | `page` (object) | Replace or tweak the page object before rendering |
| `app.page.complete` | `page` (rendered HTML) | Alter the final HTML output |
| `app.page.error` | `exception` | React to an uncaught exception |
| `template.include` | `template`, `path`, `type`, `identifier` | Swap the template file being included |
| `app.cli.init` | `identifier` | Change the console command identifier |
| `app.cli.complete` | `identifier`, `exit` | Change the console exit code |

## Creating a hook

### 1. Register the hook

Declare the hook in `packages/{Package}/etc/hooks.php`:

```php
<?php

$config = [
    Core_Hook_Interface::TYPE => [
        '{hook.name}' => [
            '{unique_name}' => '{hook_identifier}',
        ],
    ],
];
```

| Element | Description |
| --- | --- |
| {hook.name} | Anchor point name (e.g., custom.hook.demo) |
| {unique_name} | Unique key for this hook |
| {hook_identifier} | The Hook identifier (e.g., **demo** = `{Package}_Hook_Demo`) |

### 2. Implement the interface

Create a class in the `Hook` directory of your package:

```php
<?php

declare(strict_types=1);

class Acme_Hook_Demo implements Core_Hook_Interface
{
    public function process(array &$data): void
    {
        $data['foo'] = 'bar';
    }
}
```

> The `$data` parameter is passed by reference. Any modification will be reflected in the original data.

### Example

```php
<?php

$config = [
    Core_Hook_Interface::TYPE => [
        'checkout.cart.add' => [
            'log_cart_add' => 'cart_logger', // Acme_Hook_Cart_Logger
        ],
    ],
];
```

## Triggering a hook

Use `App::hook()` where needed:

```php
<?php

$data = [
    'product_id' => 123,
    'quantity' => 2,
];

App::hook('checkout.cart.add', $data);
```


# Custom shared libraries

You can add custom libraries shared by all packages.

Add the classes in the `lib` directory from the project root. Create the `lib` directory if not exists.

## Example

```php
<?php
// lib/Tools.php

declare(strict_types=1);

class Tools
{
    public function format(string $value): string
    {
        return ucfirst(strtolower($value));
    }
}
```

You can then instantiate the class anywhere.

```php
<?php

$tools = new Tools();

echo $tools->format('Hello World!');
```


# External libraries with composer

Feel free to use external libraries.

```
composer require symfony/var-dumper
```

```php
<?php

declare(strict_types=1);

class Acme_Page_Index extends Core_Page
{
    public function execute(): void
    {
        dump('foobar');
    }
}
```

```
composer require nette/utils
```

```php
<?php

declare(strict_types=1);

use Nette\Utils\Floats;

class Acme_Page_Index extends Core_Page
{
    public function execute(): void
    {
        echo Floats::areEqual(0.1 + 0.2, 0.3) ? 'same' : 'not same';
    }
}
```


# Write content in Markdown

## The Magedown library

MageWork ships with `Magedown`, a lightweight Markdown to HTML converter in a single file: `core/lib/Magedown.php`. No Composer dependency is required.

It supports headings (with slug `id` attributes), paragraphs, emphasis, inline code, links, images, fenced code blocks, blockquotes, ordered and unordered nested lists, horizontal rules and pipe tables. Any raw HTML in the source is escaped.

```php
<?php

$html = (new Magedown())->parse('# Hello World!');
```

| Method | Description |
| --- | --- |
| setBreaksEnabled | Convert a single line break into a `<br />` tag |
| setHeadingIds | Add a slug `id` attribute on every heading (enabled by default) |
| setHighlightEnabled | Colorize `php` and `phtml` fenced code blocks with `highlight_string()` |
| setBaseUrl | Prefix relative link and image URLs with the given base URL |
| setAttributesEnabled | Enable the optional `{name="value"}` custom attribute syntax |
| setAllowedAttributes | Replace the list of attribute names accepted by the syntax |

```php
<?php

$html = (new Magedown())
    ->setBreaksEnabled(true)
    ->parse($content);
```

## Custom attributes

When `setAttributesEnabled()` is called, a `{name="value"}` block adds HTML attributes to the element it follows. Only the `name="value"` form is recognised, with double quotes: `{.class}`, `{#id}` or `{lazy}` are ignored and left as plain text. Add `\` before the brace (`\{`) to keep a literal block.

| Element | Where to place the block | Example |
| --- | --- | --- |
| Link | glued to the closing `)` | `[label](url){target="_blank" rel="noreferrer" class="link blue"}` |
| Image | glued to the closing `)` | `![alt](/media/banner.jpg){loading="lazy"}` |
| Heading | end of the line (an `id` replaces the auto slug) | `## Configuration {id="config"}` |
| Paragraph | end of the last line | `This is a note{class="message warning"}` |
| List item | end of the first line | `* Element 1{id="e1"}` |
| Blockquote, list, table, code block | alone on the line right below the block | `{class="callout"}` |

Values are always HTML escaped. Only allow-listed names are kept: `id`, `class`, `title`, `target`, `rel`, `loading`, `width`, `height`, `align`, `alt`, `hidden`, plus any `data-*` and `aria-*` name. Use `setAllowedAttributes()` to provide a different list.

```php
<?php

$html = (new Magedown())
    ->setAttributesEnabled()
    ->parse($content);
```

## Page override

To write a page content in Markdown, override the `include` method of the default **Core_Page** class (see object override).

`packages > Acme > Page.php`

```php
<?php

declare(strict_types=1);

class Acme_Page extends Core_Page
{
    public function include(?string $template): string
    {
        $markdown = App::getPackagePath('template' . DS . $template . '.md');

        if (!is_file($markdown)) {
            return parent::include($template);
        }

        return (new Magedown())->parse(file_get_contents($markdown));
    }
}
```

The content template is now loaded from a `.md` file when it exists, and falls back to the `.phtml` file otherwise.

## Page configuration

Add a new page with the content path (see create a page).

`packages > Acme > etc > pages.php`

```php
<?php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/markdown.html' => [
            'content' => 'content/my-markdown-content',
            'meta_title' => 'Markdown Content',
            'meta_description' => 'The content of this page is written in Markdown',
        ],
        /* ... */
    ],
];
```

## Markdown file

`packages > Acme > template > content > my-markdown-content.md`

```
# Hello World!

Welcome to my website.
```


# Static Site Generator

With the native `Static` package, you can generate a static version of any package and serve the files directly.

The site will be generated in the `static/{package}` folder, then you can configure the web server to use this folder as root directory.

> The static package can be modified and improved for specific needs.

## Command

```
bin/magework Static build {package} {baseUrl}
```

With optional environment variable:

```
MW_ENVIRONMENT={environment} bin/magework Static build {package} {baseUrl}
```

> If the `baseUrl` is missing, pages will use "/" as the base URL and the site can be served on any domain.

> All files present at the root of the package's asset folder will be copied to the root of the site (robots.txt, favicon.ico...), except if ignored in the [configuration](#configuration).

## Example

For example, if your application contains a package named `Acme` and you want to serve it at the URL `https://www.example.com`:

```
bin/magework Static build Acme https://www.example.com
```

## Configuration

You can create a configuration file in the package to be generated to ignore routes or files.

```php
<?php
// packages/Acme/etc/static.php

$config = [
    'static' => [
        'ignore_files' => [ // Regex only
            '/(.*)README.md$/i',
            '/(.*)CHANGELOG.md$/i',
            '/(.*)LICENCE$/i',
            '/(.*)\.php$/i',
        ],
        'ignore_routes' => [ // Regex only
            '/\/secret.html/i',
            '/\/secret\/(.*)/i',
        ],
    ],
];
```


