Skip to content

Tutorial - Vökuró


Vökuró

Vökuró is a sample application, showcasing a typical web application written in Phalcon. This application focuses on:

  • User login (security)
  • User signup (security)
  • User permissions (ACL)
  • User and profile management
  • Forms with validation
  • Transactional e-mail

Vökuró runs on two Phalcon distributions from the same source tree:

  • Phalcon v5 minimum 5.0 - the C extension. This is the default when the extension is loaded.
  • Phalcon v6 currently 6.0 alpha - the phalcon/phalcon PHP package. No extension is required.
  • The two are mutually exclusive at runtime. When the v5 extension is loaded, PHP uses it and the v6 package is shadowed.

NOTE

You can use Vökuró as a starting point for your application and enhance it further to meet your needs. By no means is this a perfect application, and it does not fit all needs.

WARNING

This tutorial assumes that you are familiar with the concepts of the Model View Controller design pattern. (see References at the end of this tutorial)

WARNING

Note the code below has been formatted to increase readability.

Installation

There are three ways to obtain and run Vökuró. Pick the one that matches your workflow:

Method Best for Phalcon version installed
Composer (create-project) A local PHP host, quickest bootstrap v6 package (v5 optional via PIE)
Docker A full stack with database and mail catcher v5 extension (v6 via build arg)
Local (non-Docker) Full control over the host environment v5 extension or v6 package

Requirements

  • PHP 8.1 through 8.5.
  • The openssl extension. Depending on your database, one of pdo_mysql, pdo_pgsql, or pdo_sqlite. The mbstring and intl extensions are also used.
  • One of MySQL 8.0, PostgreSQL, or SQLite as the data store.
  • Composer.
  • For the v5 path, the Phalcon C extension. See the installation page for the full list of platforms and install methods.

Composer (create-project)

This is the shortest path on a local PHP host. Composer downloads the application from Packagist, installs the dependencies, and runs a post-create hook:

composer create-project phalcon/vokuro vokuro
cd vokuro

The post-create-project-cmd hook copies resources/.env.example to .env if no .env exists, then prints the next steps to the terminal:

Vokuro is ready (.env created from resources/.env.example). Next steps:
  docker: docker compose up -d --build, then composer migrate && composer seed
  local:  see docs/installation.md (PIE installs the v5 extension; v6 runs as-is)
  tests:  vendor/bin/talon run

Out of the box the application runs on the bundled phalcon/phalcon (v6) package, so no extension is required. To run on the Phalcon v5 C extension instead, install it with PIE (see the installation page). Once the extension is loaded, PHP prefers it automatically and the bundled v6 package is shadowed. It can stay installed.

After the project is created, edit .env for your database, then create and seed the schema:

composer migrate
composer seed

Serve the application with the built-in PHP web server:

php -S localhost:8080 -t public .htrouter.php

Docker

The Docker stack requires nothing on the host except Docker itself. No PHP, no extensions, and no database are needed locally. The stack defines three services: the application (app), a MySQL 8.0 database (mysql), and a Mailpit SMTP catcher (mailpit) that captures outgoing e-mail so nothing leaves the host.

From the project root:

cp resources/.env.example .env
docker compose up -d --build

# Create and seed the database (migrations are not run on boot)
docker compose exec app composer migrate
docker compose exec app composer seed

Then open:

  • Application: http://localhost:8080
  • Mailpit (captured e-mails): http://localhost:8025

Log in with one of the seeded accounts:

E-mail Password
[email protected] password1
[email protected] password2
[email protected] password3
[email protected] password4

NOTE

app is the Compose service name used by docker compose exec. The running container is named ${PROJECT_PREFIX}-app, which is vokuro-app by default. If you address it with plain docker exec, use the container name, for example docker exec vokuro-app composer migrate.

Choosing the Phalcon version

The image installs one distribution at a time, selected by the PHALCON_VARIANT build argument. The v5 image compiles the C extension. The v6 image installs the phalcon/phalcon package instead.

docker compose up -d --build                      # v5 (C extension, default)
PHALCON_VARIANT=v6 docker compose up -d --build   # v6 (phalcon/phalcon, alpha)

Choosing the PHP version

The image is built for one PHP version at a time, selected by the PHP_VERSION build argument. The default is 8.5, and 8.1 through 8.5 are supported:

docker compose up -d --build                  # PHP 8.5 (default)
PHP_VERSION=8.1 docker compose up -d --build  # PHP 8.1

The container keeps the same name across rebuilds, so each rebuild replaces the previous one. To run several versions side by side, give each its own Compose project and prefix:

PHP_VERSION=8.1 PROJECT_PREFIX=vokuro81 docker compose -p vokuro81 up -d --build

Local (non-Docker)

To run Vökuró directly on your host, follow the walkthrough in the repository's own docs/installation.md. The steps are:

  1. Install the Phalcon v5 extension with PIE. To run on v6 instead, skip this step and require the package with composer require phalcon/phalcon:^6.0@alpha.
  2. Install the PHP dependencies with composer install.
  3. Copy resources/.env.example to .env and point DB_HOST at your host (the Docker defaults use the mysql service name, so a local host uses 127.0.0.1).
  4. Create and seed the database with composer migrate and composer seed.
  5. Serve the application with php -S localhost:8080 -t public .htrouter.php.

Once the configuration is in place, visiting the address will present a screen similar to this:

Structure

Vökuró follows the PDS skeleton layout. The application source lives under src, the views under themes, and all tooling configuration under resources:

vokuro/
    config
    docs
    public
        css
        img
        js
    resources
        docker
        migrations
        seeds
    src
        Controllers
        Forms
        Models
        Plugins
        Providers
    tests
        Browser
        Functional
        Support
        Unit
    themes
        vokuro
    var
Directory Description
config Application configuration (ACL, config, providers, routes)
docs Documentation
public Entry point for the application, css, js, images
resources Tooling configuration, Docker, Phinx, migrations, seeds
resources/docker Dockerfile and related build files
resources/migrations Phinx database migrations
resources/seeds Phinx database seeders
src Where the application lives (controllers, forms, etc.)
src/Controllers Controllers
src/Forms Forms
src/Models Database models
src/Plugins Plugins (ACL, Auth, Mail)
src/Providers Providers: register services in the DI container
tests PHPUnit suites (unit, functional, browser)
themes Themes/views for customization
themes/vokuro Default theme for the application
var Runtime cache and logs

Configuration

.env

Vökuró uses the Dotenv library by Vance Lucas. The library reads a .env file in the project root, which holds configuration parameters such as the database host, username, and password. A resources/.env.example file ships with the application. Copy it to .env and edit it to match your environment. The create-project hook performs this copy for you.

The available options are:

Option Description
PROJECT_PREFIX Prefix for the Docker container and Compose project names. Defaults to vokuro.
APP_PORT Host port mapped to the container's port 8080. Defaults to 8080.
MAILPIT_HTTP Host port for the Mailpit web UI. Defaults to 8025.
UID / GID Host user and group id used inside the container. Override to match your host user if it is not 1000:1000.
APP_CRYPT_SALT Random, long string used by the Phalcon\Encryption\Crypt component to produce passwords and other security features.
APP_BASE_URI Usually / if the web server points directly to the Vökuró directory. Adjust it if you install Vökuró in a subdirectory.
APP_PUBLIC_URL The public URL of the application. Used in the e-mails.
DB_ADAPTER The database adapter. Available adapters are mysql, pgsql, and sqlite. Ensure the relevant PDO extension is installed.
DB_HOST The database host. In Docker this is the mysql service name. On a local host it is usually 127.0.0.1.
DB_PORT The database port.
DB_USERNAME The database username.
DB_PASSWORD The database password.
DB_NAME The database name.
MAIL_FROM_NAME The FROM name for outgoing e-mail.
MAIL_FROM_EMAIL The FROM address for outgoing e-mail.
MAIL_SMTP_SERVER The SMTP server. In Docker this is the mailpit service name.
MAIL_SMTP_PORT The SMTP port. Mailpit listens on 1025.
MAIL_SMTP_SECURITY The SMTP security, for example tls. Empty for Mailpit.
MAIL_SMTP_USERNAME The SMTP username. Empty for Mailpit.
MAIL_SMTP_PASSWORD The SMTP password. Empty for Mailpit.

Database

Vökuró uses Phinx for migrations and seeding. Phinx reads its own configuration file, resources/phinx.php, which in turn reads the .env file. You do not need to edit phinx.php. All database settings live in .env.

Migrations and seeders are wrapped in Composer scripts. To create the schema and load the sample data:

composer migrate
composer seed

Inside the Docker stack, run them through the container:

docker compose exec app composer migrate
docker compose exec app composer seed

Migrations are not run on container boot. They are decoupled so you control when the schema changes.

Config files

The config/ folder holds four files. You do not need to change them to start the application, but this is where you customize behavior.

acl.php

Returns an array of private resources, keyed by controller with an array of actions. These controller/action pairs require an authenticated user:

<?php

declare(strict_types=1);

return [
    'private' => [
        'users' => [
            'index',
            'search',
            'edit',
            'create',
            'delete',
            'changePassword',
        ],
        'profiles' => [
            'index',
            'search',
            'edit',
            'create',
            'delete',
        ],
        'permissions' => [
            'index',
        ],
    ],
];

If you use Vökuró as a starting point, modify this file to add or remove the routes that must sit behind the login mechanism.

NOTE

Keeping the private routes in an array is efficient and easy to maintain for a small to medium application. Once your application grows, consider a different technique such as a database with a caching mechanism.

config.php

Holds the configuration parameters that Vökuró needs. Most values are populated from .env through Dotenv, so you rarely change this file. It also defines the log, view, cache, and session paths.

One value to note is useMail. Set it to false to stop Vökuró from connecting to a mail server when a user registers. This is useful on a local machine or in a test environment.

providers.php

Contains the list of provider classes that Vökuró registers in the DI container. Add a class here to register a new service. Remove or comment out a class to disable one.

routes.php

Contains the application-specific routes. The router already registers the default route, so this file only defines non-standard routes. Vökuró registers two:

<?php

declare(strict_types=1);

use Phalcon\Mvc\Router;

/**
 * @var Router $router
 */

$router->add('/confirm/{code}/{email}', [
    'controller' => 'user_control',
    'action'     => 'confirmEmail',
]);

$router->add('/reset-password/{code}/{email}', [
    'controller' => 'user_control',
    'action'     => 'resetPassword',
]);

The default route remains:

/:controller/:action/:parameters

Providers

Vökuró uses classes called Providers to register services in the DI container. This is one way to register services. Nothing stops you from putting all registrations in a single file.

Vökuró uses one file per service, plus a config/providers.php array that lists the classes to register. This keeps each service in a small, separate file, and lets you register or disable a service by editing the array without deleting files.

The provider classes are in src/Providers. Each one implements the Phalcon\Di\ServiceProviderInterface interface. See the bootstrapping section below.

Composer

Vökuró uses Composer to install its PHP dependencies. The runtime dependencies are:

"require": {
    "php": ">=8.1",
    "ext-openssl": "*",
    "robmorgan/phinx": "^0.16",
    "symfony/mailer": "^6.4",
    "vlucas/phpdotenv": "^5.6"
}

The Phalcon extension is not a hard requirement. It is listed under suggest, because the application can run on the phalcon/phalcon (v6) package that is pulled in as a development dependency. For a fresh install:

composer install

To upgrade existing packages:

composer update

The composer.json autoload entry maps the Vokuro namespace to the src folder and loads the helper functions:

"autoload": {
    "psr-4": {
        "Vokuro\\": "src/"
    },
    "files": [
        "src/Helpers.php"
    ]
}

Composer scripts

Vökuró defines Composer scripts for quality checks, tests, and the database. Run them on the host, or inside the container with docker compose exec app composer <script>:

Script Description
composer cs PHP_CodeSniffer (PSR-12)
composer cs-fix Auto-fix coding standard issues (phpcbf)
composer cs-fixer PHP CS Fixer (dry run)
composer cs-fixer-fix Apply PHP CS Fixer
composer analyze PHPStan static analysis (run where the v5 extension is not loaded)
composer test PHPUnit suites (unit, functional, browser) via talon
composer test-coverage PHPUnit plus Clover coverage in tests/_output/coverage.xml
composer migrate Run the database migrations (Phinx)
composer seed Seed the database

NOTE

composer analyze resolves Phalcon classes from the phalcon/phalcon (v6) source, so run it where the v5 C extension is not loaded, such as the CI quality job or a plain host.

Bootstrapping

Entry

The entry point is public/index.php. It bootstraps and runs the application, and serves as the single point of entry, which makes it easier to trap errors and protect files.

<?php

use Vokuro\Application as VokuroApplication;

error_reporting(E_ALL);
$rootPath = dirname(__DIR__);

try {
    require_once $rootPath . '/vendor/autoload.php';

    /**
     * Load .env configuration
     */
    Dotenv\Dotenv::createUnsafeImmutable($rootPath)->safeLoad();

    /**
     * Run Vökuró
     */
    echo (new VokuroApplication($rootPath))->run();
} catch (Exception $e) {
    echo $e->getMessage(), '<br>';
    echo nl2br(htmlentities($e->getTraceAsString()));
}

First, the file enables full error reporting. You can change this, or move the setting into your .env file.

A try/catch block wraps all operations, so errors are caught and displayed on screen.

DANGER

This is tutorial code, not a production application. If a database error occurs, the catch block echoes the exception, which can contain the database credentials. Rework this code before deploying.

The composer autoloader is loaded so the supporting libraries and the Vokuro namespaced classes are available. The environment variables from .env are then loaded with createUnsafeImmutable($rootPath)->safeLoad(). Finally, the application runs.

Requests are routed through .htrouter.php when you use the built-in PHP web server. It serves existing files from public directly and forwards everything else to public/index.php:

<?php

$uri = urldecode(
    parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)
);

if ($uri !== '/' && file_exists(__DIR__ . '/public' . $uri)) {
    return false;
}

$_GET['_url'] = $_SERVER['REQUEST_URI'];

require_once __DIR__ . '/public/index.php';

Application

All the application logic is wrapped in the Vokuro\Application class:

<?php

declare(strict_types=1);

namespace Vokuro;

use Phalcon\Di\DiInterface;
use Phalcon\Di\FactoryDefault;
use Phalcon\Di\ServiceProviderInterface;
use Phalcon\Http\ResponseInterface;
use Phalcon\Mvc\Application as MvcApplication;

class Application
{
    public const APPLICATION_PROVIDER = 'bootstrap';

    /**
     * @var MvcApplication
     */
    protected $app;

    /**
     * @var DiInterface
     */
    protected $di;

    /**
     * @var string
     */
    protected $rootPath;

    /**
     * @param string $rootPath
     *
     * @throws Exception
     */
    public function __construct(string $rootPath)
    {
        $this->di       = new FactoryDefault();
        $this->app      = $this->createApplication();
        $this->rootPath = $rootPath;

        $this->di->setShared(self::APPLICATION_PROVIDER, $this);

        $this->initializeProviders();
    }

    /**
     * @return string
     */
    public function getRootPath(): string
    {
        return $this->rootPath;
    }

    /**
     * @return string
     * @throws Exception
     */
    public function run(): string
    {
        $baseUri  = $this->di->getShared('url')->getBaseUri();
        $position = strpos($_SERVER['REQUEST_URI'], $baseUri) + strlen($baseUri);
        $uri      = '/' . substr($_SERVER['REQUEST_URI'], $position);

        /** @var ResponseInterface $response */
        $response = $this->app->handle($uri);

        return (string) $response->getContent();
    }

    /**
     * @return MvcApplication
     */
    protected function createApplication(): MvcApplication
    {
        return new MvcApplication($this->di);
    }

    /**
     * @throws Exception
     */
    protected function initializeProviders(): void
    {
        $filename = $this->rootPath . '/config/providers.php';
        if (!file_exists($filename) || !is_readable($filename)) {
            throw new Exception(
                'File providers.php does not exist or is not readable.'
            );
        }

        $providers = require $filename;
        foreach ($providers as $providerClass) {
            /** @var ServiceProviderInterface $provider */
            $provider = new $providerClass();
            $provider->register($this->di);
        }
    }
}

The constructor creates a Phalcon\Di\FactoryDefault container, which has many services already registered. It then creates a Phalcon\Mvc\Application and stores the root path.

The class registers itself in the DI container under the name bootstrap, so the application is reachable from anywhere through the container.

The constructor then registers the providers. It reads config/providers.php, loops through the class names, and calls register() on each with the DI container. Each provider implements the Phalcon\Di\ServiceProviderInterface interface.

The available providers are:

Provider Description
AclProvider Permissions
AssetsProvider CSS and JavaScript assets
AuthProvider Authentication
ConfigProvider Configuration values
CryptProvider Encryption
DbProvider Database access
DispatcherProvider Dispatcher - which controller to call for a URL
FlashProvider Flash messages for feedback to the user
LoggerProvider Logger for errors and other information
MailProvider Mail support
ModelsMetadataProvider Metadata for models
RouterProvider Routes
SecurityProvider Security
SessionBagProvider Session data
SessionProvider Session
UrlProvider URL handling
ViewProvider Views and view engine

run() computes the request URI relative to the configured base URI, hands it to the application, and returns the content. Internally the application calculates the route, dispatches the controller and view, and returns the response.

Database

Vökuró can be installed with MariaDB/MySQL/Aurora, PostgreSQL, or SQLite. For this tutorial, we use MySQL. The tables the application uses are:

Table Description
email_confirmations Email confirmations for registration
failed_logins Failed login attempts
password_changes When a password was changed and by whom
permissions Permission matrix
phinxlog Phinx migration log
profiles Profile for each user
remember_tokens Remember Me functionality tokens
reset_passwords Reset password tokens
success_logins Successful login attempts
users Users

Models

Following the Model-View-Controller pattern, Vökuró has one model per database table (excluding phinxlog). The models let you interact with the tables in an object-oriented manner. They live in src/Models. Each model defines its fields, source table, and any relationships. Some models also define validation rules.

<?php

declare(strict_types=1);

namespace Vokuro\Models;

use Phalcon\Mvc\Model;

class SuccessLogins extends Model
{
    /**
     * @var integer
     */
    public $id;

    /**
     * @var integer
     */
    public $usersId;

    /**
     * @var string
     */
    public $ipAddress;

    /**
     * @var string
     */
    public $userAgent;

    public function initialize()
    {
        $this->belongsTo(
            'usersId',
            Users::class,
            'id',
            [
                'alias' => 'user',
            ]
        );
    }
}

In the model above, all the table fields are declared as public properties for direct access:

echo $successLogin->ipAddress;

WARNING

The property names match the case (upper/lower) of the field names in the table.

In initialize(), the model defines a relationship to the Users model, with local and remote fields and an alias. The related user is then reachable through the alias:

echo $successLogin->user->name;

NOTE

Open each model file to see the relationships between the models. Check the documentation for the difference between the various types of relationships.

Controllers

Following the Model-View-Controller pattern, Vökuró has one controller per parent route. The AboutController handles the /about route. All controllers live in src/Controllers.

The default controller is IndexController. Controller classes carry the Controller suffix. Actions carry the Action suffix, and the default action is indexAction. Visiting the site at the root URL calls IndexController and runs indexAction.

Unless you register specific routes, the default route maps:

/profiles/search

to

/src/Controllers/ProfilesController.php -> searchAction

The available controllers, actions, and routes for Vökuró are:

Controller Action Route Description
About index /about Shows the about page
Index index / Default action - home page
Permissions index /permissions View/change permissions for a profile level
Privacy index /privacy View the privacy page
Profiles index /profiles View profiles default page
Profiles create /profiles/create Create profile
Profiles delete /profiles/delete Delete profile
Profiles edit /profiles/edit Edit profile
Profiles search /profiles/search Search profiles
Session index /session Session default action
Session forgotPassword /session/forgotPassword Forgot password
Session login /session/login Login
Session logout /session/logout Logout
Session signup /session/signup Signup
Terms index /terms View the terms page
UserControl confirmEmail /confirm Confirm email
UserControl resetPassword /reset-password Reset password
Users index /users Users default screen
Users changePassword /users/changePassword Change user password
Users create /users/create Create user
Users delete /users/delete Delete user
Users edit /users/edit Edit user

Views

The last element of the Model-View-Controller pattern is the views. Vökuró uses Volt as the view engine.

NOTE

You would normally expect a views folder under src. Vökuró stores all view files under themes/vokuro instead.

The views directory contains one directory per controller. Inside each, .volt files map to each action. The route:

/profiles/create

maps to:

ProfilesController -> createAction

and the view is:

/themes/vokuro/profiles/create.volt

The available views are:

Controller Action View Description
About index /about/index.volt Shows the about page
Index index /index/index.volt Default action - home page
Permissions index /permissions/index.volt View/change permissions for a profile level
Privacy index /privacy/index.volt View the privacy page
Profiles index /profiles/index.volt View profiles default page
Profiles create /profiles/create.volt Create profile
Profiles delete /profiles/delete.volt Delete profile
Profiles edit /profiles/edit.volt Edit profile
Profiles search /profiles/search.volt Search profiles
Session index /session/index.volt Session default action
Session forgotPassword /session/forgotPassword.volt Forgot password
Session login /session/login.volt Login
Session logout /session/logout.volt Logout
Session signup /session/signup.volt Signup
Terms index /terms/index.volt View the terms page
Users index /users/index.volt Users default screen
Users changePassword /users/changePassword.volt Change user password
Users create /users/create.volt Create user
Users delete /users/delete.volt Delete user
Users edit /users/edit.volt Edit user

The index.volt file contains the main layout of the page, including stylesheet and JavaScript references. The layouts directory contains layouts used across the application, for instance a public one for anonymous users and a private one for logged-in users. Individual views are injected into the layouts to form the final page.

Components

Vökuró uses several components that provide functionality across the application. They live in src/Plugins.

Acl

Vokuro\Plugins\Acl\Acl implements an Access Control List for the application. The ACL controls which user has access to which resource. See the ACL page for details.

The component reads the private resources from config/acl.php, the controller/action pairs that require authentication. It also holds human-readable descriptions for the actions used throughout the application.

The component exposes the following methods:

Method Returns Description
addPrivateResources(array $resources) void Adds private resources to the ACL
getAcl() AclMemory Returns the ACL list
getActionDescription($action) string Returns the action description from its simplified name
getPermissions(Profiles $profile) array Returns the permissions assigned to a profile
getResources() array Returns all the resources and their actions
isAllowed($profile, $controller, $action) bool Checks whether the profile is allowed to access a resource
isPrivate($controllerName) bool Checks whether a controller is private
rebuild() AclMemory Rebuilds the access list into a file

Auth

Vokuro\Plugins\Auth\Auth manages authentication and identity in Vökuró.

The component exposes the following methods:

Method Description
authUserById($id) Authenticates the user by id
check($credentials) Checks the user credentials
checkUserFlags(Users $user) Checks whether the user is banned, inactive, or suspended
createRememberEnvironment(Users $user) Creates the Remember Me cookies and tokens
deleteToken(int $userId) Deletes the current user token in the session
findFirstByToken($token) Returns the user for the current token
getIdentity() Returns the current identity
getName() Returns the name of the user
getUser() Returns the entity related to the active identity
hasRememberMe() Checks whether the session has a Remember Me cookie
loginWithRememberMe() Logs in using the information in the cookies
registerUserThrottling($userId) Implements login throttling to reduce the effectiveness of brute-force attacks
remove() Removes the user identity from the session
saveSuccessLogin($user) Records a successful login

Mail

Vokuro\Plugins\Mail\Mail is a wrapper around Symfony Mailer. It exposes buildMessage(), getTemplate(), and send(). getTemplate() reads a template from the views and populates it with data. buildMessage() assembles a Symfony\Component\Mime\Email from its arguments. send() renders a template and sends the message.

Method Returns Description
buildMessage($to, $subject, $html, $from, $name) Email Builds the message. All inputs are passed in, nothing is read from the container
getTemplate($name, $params) string Renders a view template with the supplied parameters
send($to, $subject, $name, $params) int Renders a template and sends the message

NOTE

This component is used only when useMail is enabled in config/config.php. In Docker, e-mails are delivered to the Mailpit catcher and never leave the host. Its web UI is at http://localhost:8025.

Sign Up

Controller

To access all the areas of Vökuró you need an account. Vökuró lets you sign up by clicking the Create an Account button.

This navigates to /session/signup, which calls SessionController and signupAction:

<?php

declare(strict_types=1);

namespace Vokuro\Controllers;

use Phalcon\Flash\Direct;
use Phalcon\Http\Request;
use Phalcon\Mvc\Dispatcher;
use Phalcon\Mvc\View;
use Phalcon\Encryption\Security;
use Vokuro\Forms\SignUpForm;
use Vokuro\Models\Users;

/**
 * @property Dispatcher $dispatcher
 * @property Direct     $flash
 * @property Request    $request
 * @property Security   $security
 * @property View       $view
 */
class SessionController extends ControllerBase
{
    public function signupAction()
    {
        $form = new SignUpForm();

        // ....

        $this->view->setVar('form', $form);
    }
}

The workflow of the application is:

  • Visit /session/signup
    • Create form, send form to the view, render the form
  • Submit data (not post)
    • Form shows again, nothing else happens
  • Submit data (post)
    • Errors
      • Form validators have errors, send the form to the view, render the form (errors show)
    • No errors
      • Data is sanitized
      • New model created
      • Data saved in the database
        • Error
          • Show the message and refresh the form
        • Success
          • Record saved
          • Show confirmation
          • Send email (if applicable)

Form

To validate user-supplied data, Vökuró uses Phalcon\Forms\Form and the Phalcon\Filter\Validation classes. These create HTML elements and attach validators. The form is passed to the view, which renders the HTML elements. When the user submits data, the posted data is passed back to the form, and the validators return any error messages.

NOTE

All forms for Vökuró are in src/Forms.

The SignUpForm object defines every HTML element with its validators:

<?php

declare(strict_types=1);

namespace Vokuro\Forms;

use Phalcon\Filter\Validation\Validator\Confirmation;
use Phalcon\Filter\Validation\Validator\Email;
use Phalcon\Filter\Validation\Validator\Identical;
use Phalcon\Filter\Validation\Validator\PresenceOf;
use Phalcon\Filter\Validation\Validator\StringLength;
use Phalcon\Forms\Element\Check;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Forms\Element\Password;
use Phalcon\Forms\Element\Submit;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Form;

class SignUpForm extends Form
{
    /**
     * @param mixed $entity
     * @param array $options
     */
    public function initialize($entity = null, array $options = [])
    {
        $name = new Text('name');
        $name->setLabel('Name');
        $name->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'The name is required',
                    ]
                ),
            ]
        );

        $this->add($name);

        $email = new Text('email');
        $email->setLabel('E-Mail');
        $email->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'The e-mail is required',
                    ]
                ),
                new Email(
                    [
                        'message' => 'The e-mail is not valid',
                    ]
                ),
            ]
        );

        $this->add($email);

        $password = new Password('password');
        $password->setLabel('Password');
        $password->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'The password is required',
                    ]
                ),
                new StringLength(
                    [
                        'min'            => 8,
                        'included'       => true,
                        'messageMinimum' => 'Password is too short. ' .
                                            'Minimum 8 characters',
                    ]
                ),
                new Confirmation(
                    [
                        'message' => "Password doesn't match " .
                                     "confirmation",
                        'with'    => 'confirmPassword',
                    ]
                ),
            ]
        );

        $this->add($password);

        $confirmPassword = new Password('confirmPassword');
        $confirmPassword->setLabel('Confirm Password');
        $confirmPassword->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'The confirmation password ' .
                                     'is required',
                    ]
                ),
            ]
        );

        $this->add($confirmPassword);

        $terms = new Check(
            'terms',
            [
                'value' => 'yes',
            ]
        );

        $terms->setLabel('Accept terms and conditions');
        $terms->addValidator(
            new Identical(
                [
                    'value'   => 'yes',
                    'message' => 'Terms and conditions must be ' .
                                 'accepted',
                ]
            )
        );

        $this->add($terms);

        $csrf = new Hidden('csrf');
        $csrf->addValidator(
            new Identical(
                [
                    'value'   => $this->security->getRequestToken(),
                    'message' => 'CSRF validation failed',
                ]
            )
        );
        $csrf->clear();

        $this->add($csrf);

        $this->add(
            new Submit(
                'Sign Up',
                [
                    'class' => 'btn btn-success',
                ]
            )
        );
    }

    /**
     * @param string $name
     *
     * @return string
     */
    public function messages(string $name)
    {
        if ($this->hasMessagesFor($name)) {
            foreach ($this->getMessagesFor($name) as $message) {
                return $message;
            }
        }

        return '';
    }
}

In initialize() the form sets up all the HTML elements:

Element Type Description
name Text The name of the user
email Text The email for the account
password Password The password for the account
confirmPassword Password Password confirmation
terms Check Accept the terms checkbox
csrf Hidden CSRF protection element
Sign Up Submit Submit button

Adding an element is straightforward:

<?php

declare(strict_types=1);

use Phalcon\Filter\Validation\Validator\Email;
use Phalcon\Filter\Validation\Validator\PresenceOf;
use Phalcon\Forms\Element\Text;

$email = new Text('email');
$email->setLabel('E-Mail');
$email->addValidators(
    [
        new PresenceOf(
            [
                'message' => 'The e-mail is required',
            ]
        ),
        new Email(
            [
                'message' => 'The e-mail is not valid',
            ]
        ),
    ]
);

$this->add($email);

First, a Text object is created with the name email and the label E-Mail. Validators are then attached. They are invoked after the user submits the data.

The PresenceOf validator produces the message The e-mail is required when the field is empty. It checks the passed array (usually $_POST), in this case $_POST['email']. The Email validator checks for a valid address. Validators belong in an array, so you attach as many as you need to any element. The last step adds the element to the form.

The terms element has an Identical validator that requires the checkbox value to equal yes.

The password and confirmPassword elements are both of type Password. The user must type the password twice, and the two must match. The password field has a PresenceOf validator (it is required) and a StringLength validator (at least 8 characters). It also has a Confirmation validator that ties password to confirmPassword. When it runs, it compares both elements. If they differ, validation fails.

View

The form is passed to the view:

$this->view->setVar('form', $form);

The view renders the elements:

{# ... #}
{%
    set isEmailValidClass = form.messages('email') ?
        'form-control is-invalid' :
        'form-control'
%}
{# ... #}

<h1 class="mt-3">Sign Up</h1>

<form method="post">
    {# ... #}

    <div class="form-group row">
        {{
            form.label(
                'email',
                [
                    'class': 'col-sm-2 col-form-label'
                ]
            )
        }}
        <div class="col-sm-10">
            {{
                form.render(
                    'email',
                    [
                        'class': isEmailValidClass,
                        'placeholder': 'Email'
                    ]
                )
            }}
            <div class="invalid-feedback">
                {{ form.messages('email') }}
            </div>
        </div>
    </div>

    {# ... #}
    <div class="form-group row">
        <div class="col-sm-10">
            {{
                form.render(
                    'csrf',
                    [
                        'value': security.getToken()
                    ]
                )
            }}
            {{ form.messages('csrf') }}

            {{ form.render('Sign Up') }}
        </div>
    </div>
</form>

<hr>

{{ link_to('session/login', "&larr; Back to Login") }}

The view variable for the SignUpForm object is form. In PHP you call $form->render(); in Volt you call form.render().

The conditional at the top checks whether the form has errors, and if so attaches the is-invalid CSS class to the element. This class adds a red border and shows the message.

To render each element, call render() on form with the element name. form.label() with the same name creates the <label> tag. At the end, the view renders the csrf hidden field and the Sign Up submit button.

Post

When the user fills out the form and clicks Sign Up, the form self-posts to the same controller and action (/session/signup). The action processes the posted data:

<?php

declare(strict_types=1);

namespace Vokuro\Controllers;

use Phalcon\Flash\Direct;
use Phalcon\Http\Request;
use Phalcon\Mvc\Dispatcher;
use Phalcon\Mvc\View;
use Phalcon\Encryption\Security;
use Vokuro\Forms\SignUpForm;
use Vokuro\Models\Users;

/**
 * @property Dispatcher $dispatcher
 * @property Direct     $flash
 * @property Request    $request
 * @property Security   $security
 * @property View       $view
 */
class SessionController extends ControllerBase
{
    public function signupAction()
    {
        $form = new SignUpForm();

        if (true === $this->request->isPost()) {
            if (false !== $form->isValid($this->request->getPost())) {
                $name     = $this
                    ->request
                    ->getPost('name', 'striptags')
                ;
                $email    = $this
                    ->request
                    ->getPost('email')
                ;
                $password = $this
                    ->request
                    ->getPost('password')
                ;
                $password = $this
                    ->security
                    ->hash($password)
                ;

                $user = new Users(
                    [
                        'name'       => $name,
                        'email'      => $email,
                        'password'   => $password,
                        'profilesId' => 2,
                    ]
                );

                if ($user->save()) {
                    $this->dispatcher->forward([
                        'controller' => 'index',
                        'action'     => 'index',
                    ]);
                }

                foreach ($user->getMessages() as $message) {
                    $this->flash->error((string) $message);
                }
            }
        }

        $this->view->setVar('form', $form);
    }
}

If the user submitted data, the following evaluates true, and the code inside the if runs:

if (true === $this->request->isPost()) {

The Phalcon\Http\Request object returns the posted data:

$this->request->getPost()

The posted data is passed to the form and validated with isValid(). This fires every validator. If any fail, the form collects the messages and returns false:

if (false !== $form->isValid($this->request->getPost())) {

If the data is valid, the Phalcon\Http\Request object retrieves and sanitizes each value. The example below strips tags from the name string:

$name = $this
    ->request
    ->getPost('name', 'striptags')
;

Clear-text passwords are never stored. The Phalcon\Encryption\Security component hashes the password into a one-way hash, and that is stored instead:

$password = $this
    ->security
    ->hash($password)
;

The sanitized data is stored by creating a Users model, passing the data, and calling save():

$user = new Users(
    [
        'name'       => $name,
        'email'      => $email,
        'password'   => $password,
        'profilesId' => 2,
    ]
);

if ($user->save()) {
    $this
        ->dispatcher
        ->forward(
            [
                'controller' => 'index',
                'action'     => 'index',
            ]
        );
}

If $user->save() returns true, the user is forwarded to the home page (index/index) and a success message appears.

Model

Relationships

The Users model applies logic in the afterSave and beforeValidationOnCreate events. The setup happens in initialize(), where the relationships are defined.

To check all the successful logins for a user, you could query both tables directly:

<?php

declare(strict_types=1);

use Vokuro\Models\SuccessLogins;
use Vokuro\Models\Users;

$user = Users::findFirst(
    [
        'conditions' => 'id = :id:',
        'bind'       => [
            'id' => 7,
        ],
    ]
);

$logins = SuccessLogins::find(
    [
        'conditions' => 'usersId = :usersId:',
        'bind'       => [
            'usersId' => 7,
        ],
    ]
);

With a relationship, Phalcon does the join for you:

<?php

declare(strict_types=1);

use Vokuro\Models\Users;

$user = Users::findFirst(
    [
        'conditions' => 'id = :id:',
        'bind'       => [
            'id' => 7,
        ],
    ]
);

$logins = $user->successLogins;

$logins = $user->getRelated('successLogins');

The last two lines do the same thing. Phalcon queries the related table, filtered by the user id.

For the Users table, Vökuró defines these relationships:

Name Source field Target field Model
passwordChanges id usersId PasswordChanges
profile profilesId id Profiles
resetPasswords id usersId ResetPasswords
successLogins id usersId SuccessLogins
<?php

declare(strict_types=1);

namespace Vokuro\Models;

use Phalcon\Mvc\Model;

class Users extends Model
{
    // ...

    public function initialize()
    {
        $this->belongsTo(
            'profilesId',
            Profiles::class,
            'id',
            [
                'alias'    => 'profile',
                'reusable' => true,
            ]
        );

        $this->hasMany(
            'id',
            SuccessLogins::class,
            'usersId',
            [
                'alias'      => 'successLogins',
                'foreignKey' => [
                    'message' => 'User cannot be deleted because ' .
                                 'he/she has activity in the system',
                ],
            ]
        );

        $this->hasMany(
            'id',
            PasswordChanges::class,
            'usersId',
            [
                'alias'      => 'passwordChanges',
                'foreignKey' => [
                    'message' => 'User cannot be deleted because ' .
                                 'he/she has activity in the system',
                ],
            ]
        );

        $this->hasMany(
            'id',
            ResetPasswords::class,
            'usersId',
            [
                'alias'      => 'resetPasswords',
                'foreignKey' => [
                    'message' => 'User cannot be deleted because ' .
                                 'he/she has activity in the system',
                ],
            ]
        );
    }

    // ...
}

There is one belongsTo and three hasMany relationships. Each has an alias for easier access. The belongsTo relationship also sets reusable to on. When the relationship is called more than once in the same request, Phalcon runs the query only the first time and caches the result set. Subsequent calls use the cache.

The relationships also define foreign-key messages. If a relationship is violated, the defined message is raised.

Events

Phalcon\Mvc\Model fires specific events. These methods can live in a listener or in the model. The Users model uses afterSave and beforeValidationOnCreate.

<?php

declare(strict_types=1);

namespace Vokuro\Models;

use Phalcon\Mvc\Model;

class Users extends Model
{
    public function beforeValidationOnCreate()
    {
        if (true === empty($this->password)) {
            $tempPassword = preg_replace(
                '/[^a-zA-Z0-9]/',
                '',
                base64_encode(openssl_random_pseudo_bytes(12))
            );

            $this->mustChangePassword = 'Y';

            $this->password = $this->getDI()
                                   ->getSecurity()
                                   ->hash($tempPassword)
            ;
        } else {
            $this->mustChangePassword = 'N';
        }

        if ($this->getDI()->get('config')->useMail) {
            $this->active = 'N';
        } else {
            $this->active = 'Y';
        }

        $this->suspended = 'N';
        $this->banned    = 'N';
    }
}

beforeValidationOnCreate fires on every new record, before validation. If the password is empty, it generates a random string, hashes it with Phalcon\Encryption\Security, and sets the flag to change the password. If the password is set, it sets mustChangePassword to N. It then sets the active, suspended, and banned defaults.

<?php

declare(strict_types=1);

namespace Vokuro\Models;

use Phalcon\Mvc\Model;

class Users extends Model
{
    public function afterSave()
    {
        if ($this->getDI()->get('config')->useMail) {
            if ($this->active == 'N') {
                $emailConfirmation          = new EmailConfirmations();
                $emailConfirmation->usersId = $this->id;

                if ($emailConfirmation->save()) {
                    $this->getDI()
                         ->getFlash()
                         ->notice(
                             'A confirmation mail has ' .
                             'been sent to ' . $this->email
                         )
                    ;
                }
            }
        }
    }
}

afterSave fires right after a record is saved. If e-mail is enabled (useMail in config/config.php) and the user is inactive, it creates and saves an EmailConfirmations record, then shows a notice.

NOTE

The EmailConfirmations model has an afterCreate event that sends the actual e-mail.

Validation

The model's validation() method attaches validators to fields. For Users, the email must be unique, so the Uniqueness validator is attached. It fires before any save, and returns the message if validation fails.

<?php

declare(strict_types=1);

namespace Vokuro\Models;

use Phalcon\Filter\Validation;
use Phalcon\Filter\Validation\Validator\Uniqueness;
use Phalcon\Mvc\Model;

class Users extends Model
{
    public function validation()
    {
        $validator = new Validation();

        $validator->add(
            'email',
            new Uniqueness(
                [
                    'message' => 'The email is already registered',
                ]
            )
        );

        return $this->validate($validator);
    }
}

Conclusion

Vökuró is a sample application that demonstrates features Phalcon offers. It is not a solution that fits all needs. Use it as a starting point for your own application.

References