MageWork PHP Framework

Welcome to MageWork! A standalone, lightweight PHP framework to build websites.

Please refer to the readable documentation at the following official website: MageWork documentation


Installation

Requirements

PHP

A PHP version 8.4 or higher is required.

PHP Extensions:

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

Server

MageWork is compatible with any web server.

Environment variables

You just need to set 2 environment variables:

If you can't add environment variables, you need to use etc/config.default.php as configuration file.

Root directory

Configure the web server to serve the pub directory.

Examples

Apache

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

    SetEnv MW_DEVELOPER_MODE 1
    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;

        fastcgi_param MW_DEVELOPER_MODE 1;
        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 {environment} {host} {path} {developer_mode}

Example:

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

Configuration

Configuration file

Open the configuration file for your current environment: etc/config.{MW_ENVIRONMENT}.php

MW_ENVIRONMENT is an environment variable. The default value is "default" if not defined. See Installation.

In this 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
// etc/config.default.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:

<?php
// etc/config.default.php

$config = [
    /* ... */
    'default' => [ // "default" is the configuration for all packages. Use the package name for a specific configuration.
        Core_Model::TYPE => [
            'database' => [
                'db_host' => '',
                'db_username' => '',
                'db_password' => '',
                'db_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
            ],
        ],
    ],
    /* ... */
];

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.

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

<?php
// etc/config.default.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

By default, the port is 443. If you're using a certificate on a different port, a configuration allows you to define the port. This enables the application to determine if the context is secure: App::isSsl().

<?php
// etc/config.default.php

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

These configurations are shared by all packages.

Forms

To manage forms and send emails, you can configure the contact settings.

<?php
// etc/config.default.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',
                '_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

Open the configuration file for your current environment: etc/config.{MW_ENVIRONMENT}.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:

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

If you have defined local for the MW_ENVIRONMENT variable, open: etc/config.local.php.

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

<?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. For example: etc/config.prod.php

<?php

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

Tree structure

Next, create the package directory structure:

packages > Acme > etc > pages.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

declare(strict_types=1);

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

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

packages > Acme > template > page.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

<?= 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 {environment} {host} {path} {developer_mode}

Example:

php pub/index.php local localhost.acme /

Add a new page

Create the page

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

<?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',
        ],
        /* ... */
    ],
];

Warning:

Create a new class: packages/Acme/Page/Contact.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

<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 integer
_headers Custom headers array

HTTP response status code

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

<?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

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 allow you to send custom headers:

<?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

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

<?php

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

Serve any type of file

Page

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

<?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
// 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
// packages/Acme/etc/page.php

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/sitemap.xml' => [
            'class' => Acme_Page_Sitemap::class,
            '_headers' => [
                'Content-Type' => 'application/xml',
            ],
        ],
        /* ... */
    ],
];
<?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->getBaseUrl($page['slug']) . '</loc></url>' . "\n";
        }
        $xml .= '</urlset>';

        return $xml;
    }
}

robots.txt

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

$config = [
    Core_Page::TYPE => [
        /* ... */
        '/robots.txt' => [
            'class' => Acme_Page_Robots::class,
            '_headers' => [
                'Content-Type' => 'text/plain',
            ],
        ],
        /* ... */
    ],
];
<?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
    
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
    
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.


Add a new block

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

<?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

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

<?= $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).

Finally, create the template file: packages/Acme/template/block/banner.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; ?>

Data assignment

You can simply pass data to any object or template with the configuration file: packages/Acme/etc/config.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 class or template file with the getData method or the magic getter:

<?php

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

Use global configuration file to set data to packages and objects according the environment: etc/config.{MW_ENVIRONMENT}.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

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

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

$config = [
    Core_Page::TYPE => [ // Type
        'contact' => [ // Identifier
            '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

/** @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

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

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

declare(strict_types=1);

class Acme_Page 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_Customer
  4. Core_Customer
  5. DataObject

Database

Insert

<?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

$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

$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

$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

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

Get All

<?php

$customers = App::db()
    ->getAll('customers');
    
// SELECT * FROM customers
<?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

$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

$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

$customer = App::db()
    ->where(['email =' => 'john@example.com'])
    ->getRow('customers');
    
// SELECT * FROM customers WHERE (email = 'john@example.com') LIMIT 0,1

Get Value

<?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

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

Console Commands

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

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 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

Forms

<?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); // Magework_Mail_Contact
        
        $form->setMailSubject('New contact message!');
        $form->setMailMessage($message->render());
        $form->setMailSendTo('contact@example.com');
        $form->sendMail();
    } else {
        $form->getErrorFieldName();
        $form->getErrorFieldLabel();
    }
}

Captcha

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

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:

<form action="<?= $this->getBaseUrl('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

declare(strict_types=1);

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

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

        if ($dataPost->getData('secure') !== $captcha) {
            $this->setErrorMessage('Captcha is not correct');
            $this->redirect($this->getBaseUrl('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

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

Framework tools

Page

<?php

$page = App::page();

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

<?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

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

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

The encryption key is stored in var/encryption. If the key is lost, it will be impossible to decrypt the data ever again.

Cache

<?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. You can update the lifetime before variable assignment:

<?php

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

Session

<?php

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

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

The sessions are stored in var/session.

Log

<?php

App::log('message');

The logs are stored in var/log.

The second parameter allows you to change the log level (default is "info").

<?php

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

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

Database

<?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

echo App::getConfig('app.domain');

Escaper

<?php

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

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

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

App::escaper()->escapeQuotes("that's true");
<a href="#" onclick="javascript:alert('<?= App::escaper()->escapeQuotes("that's true") ?>')">Alert</a>
<?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"
}

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
// 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

$tools = new Tools();

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

External libraries with composer

Feel free to use external libraries.

composer require symfony/var-dumper
<?php

declare(strict_types=1);

class Acme_Page_Index extends Core_Page
{
    public function execute(): void
    {
        dump('foobar');
    }
}
composer require nette/utils
<?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

Library

In this example, we use the michelf/php-markdown package (see external libraries with composer).

composer require michelf/php-markdown

You can adapt it with your preferred Markdown library.

Page override

To write the page content in markdown, we need to override the default Core_Page class (see object override).

packages > Acme > Page.php

<?php

declare(strict_types=1);

use Michelf\MarkdownExtra;

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

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

        $content = file_get_contents($markdown);

        return MarkdownExtra::defaultTransform($content);
    }
}

Page configuration

Then, we add a new page with the content path (see create a page).

packages > Acme > etc > pages.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

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.

Example

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

MW_ENVIRONMENT=default 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
// 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',
        ],
    ],
];