Controller framework basic example

10 Nov 2025 - dirkvm


Controller Framework 1.0.31 — Basic Implementation User Guide

Controller Framework 1.0.31
Basic Implementation User Guide

A step-by-step tutorial that starts with an empty PHP application and ends with a login-protected screen displaying:

Hi [name of User], welcome to our login protected environment.

and a Logout button.

Based on Controller Framework 1.0.31 and its Application Controller implementation.

What you will build
A minimal PHP 8.3 web application with Composer, the Controller Framework, a MySQL/MariaDB database, a public login screen, CSRF protection, a UserLogin-protected welcome screen and the framework's LogoutCommand.

Contents

  1. The basic concept
  2. Prerequisites
  3. Create the application structure
  4. Install the framework with Composer
  5. Create the application bootstrap
  6. Configure the framework
  7. Create the database
  8. Create the minimal Member model
  9. Create the Commands
  10. Connect everything in controls.xml
  11. Create the login and welcome views
  12. Run and test the application
  13. Understand what happens internally
  14. Where to go from here

1. The basic concept

The Application Controller variant of the framework follows a simple request lifecycle:

Browser request
index.php
Controller::run()
Init
Command
Renderer
HTML response

The important principle is that controls.xml connects a URL path to a Command and to the renderer used for its result. The Command contains the application logic; the View contains the presentation.

2. Prerequisites

Important: the tutorial uses development configuration. For a production installation, use environment=production and do not expose PHP errors to visitors.

3. Create the application structure

Start with an empty web application directory. Create this structure:

basic-app/
├── composer.json
├── index.php
├── .htaccess
├── DatabaseSetup.sql
├── config/
│   ├── app_options.ini
│   └── .htaccess
├── assets/
│   └── logging/
└── MVCFramework/
    ├── controls.xml
    ├── commands/
    │   ├── LoginCommand.php
    │   └── WelcomeCommand.php
    ├── model/
    │   ├── Member.php
    │   ├── MemberMapper.php
    │   └── Member_RGLR.php
    └── views/
        ├── loginView.php
        ├── welcomeView.php
        └── includes/
            └── head.php

This keeps framework-independent client code under MVCFramework/, while Composer installs the framework itself under vendor/.

Note: Complete minimal example

A ready-to-copy version of all files used in this tutorial is supplied as a ZIP archive alongside this guide. It contains the same application structure and code shown below.

4. Install the framework with Composer

Create composer.json:

{
    "name": "example/controller-framework-app",
    "require": {
        "php": "^8.3",
        "samoscon/controller-framework": "^1.0.31"
    }
}

Run:

composer install

Composer creates vendor/autoload.php. Your application will load this before starting the Controller Framework.

5. Create the application bootstrap

The application's only real entry point is index.php:

<?php

require __DIR__ . '/vendor/autoload.php';

spl_autoload_register(function (string $className): void {
    $file = __DIR__
        . '/MVCFramework/'
        . str_replace('\\', '/', $className)
        . '.php';

    if (is_file($file)) {
        require_once $file;
    }
});

controllerframework\controllers\Controller::run();

There are two autoloading responsibilities:

  1. Composer loads the framework classes.
  2. The small client autoloader loads your own classes from MVCFramework/.

Controller::run() starts the framework. Internally it registers the ErrorHandler, initializes the application and then handles the current request.

6. Configure the framework

Create config/app_options.ini. The minimal configuration contains a [config] section and a [globals] section because InitController requires both.

[config]
environment=development
templatepath=/MVCFramework/views
controlsfile=/MVCFramework/controls.xml
loggingpath=/assets/logging/

[globals]
APP=BASIC
_APPDIR=https://your-domain.example/
_HOMEPAGE=https://your-domain.example/
_MINLEVELTOLOGIN=U

_DBUSER=your_database_user
_DBPASSWORD="your_database_password"
_DBNAME=your_database_name
_DBHOST=localhost

For the complete framework mail/error configuration, add the mail globals shown in the supplied example project.

What happens during initialization? InitController determines the application root, loads config/app_options.ini, creates the application configuration, defines the configured globals, locates controls.xml, compiles it with RenderCompiler, and stores the resulting command map in the Registry.

Protect the configuration directory from direct HTTP access with config/.htaccess.

7. Create the database

The framework's authentication uses the client application's member table. The remember-me implementation also uses remember_tokens.

For this tutorial, create both tables and one test user.

CREATE TABLE member (
    id INT UNSIGNED NOT NULL AUTO_INCREMENT,
    description VARCHAR(190) DEFAULT 'description',
    classification VARCHAR(4) NOT NULL DEFAULT 'RGLR',
    parent_id INT UNSIGNED DEFAULT 0,
    name VARCHAR(45) DEFAULT 'name',
    lastname VARCHAR(45) DEFAULT 'lastname',
    email VARCHAR(255) DEFAULT NULL,
    role VARCHAR(5) NOT NULL DEFAULT 'U',
    password VARCHAR(256) DEFAULT NULL,
    ownpwd TINYINT(1) DEFAULT 0,
    active TINYINT(1) DEFAULT 0,
    subscriptionuntil DATE DEFAULT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_member_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE remember_tokens (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    member_id INT UNSIGNED NOT NULL,
    selector CHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
    token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
    expires_at DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_used_at DATETIME NULL DEFAULT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_remember_selector (selector),
    KEY idx_remember_member (member_id),
    KEY idx_remember_expires (expires_at),
    CONSTRAINT fk_remember_member
        FOREIGN KEY (member_id) REFERENCES member(id)
        ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Insert a test member. The password below is a PHP password_hash() result for the tutorial password Demo123!:

INSERT INTO member
    (classification, name, lastname, email, role, password, ownpwd, active)
VALUES
    (
        'RGLR',
        'Demo User',
        'Example',
        'demo@example.com',
        'U',
        '$2y$12$CM39.EaqzfUqdivsgnL8bu2Kyz1jMasOI9A8xWMoujgAYk94lvLTu',
        1,
        1
    );
Test credentials only. Do not use this password in a real application.

8. Create the minimal Member model

There is one framework-specific requirement that is easy to miss: User::getInstance() and the member framework expect the concrete client member class to be \model\Member.

8.1 Member.php

<?php

namespace model;

class Member extends \controllerframework\members\Member
{
    public function initiatePassword(string $pwd = ''): string
    {
        return 'Your temporary password is ' . htmlspecialchars(
            $pwd,
            ENT_QUOTES,
            'UTF-8'
        );
    }
}

The method is required because the framework's abstract Member declares initiatePassword().

8.2 MemberMapper.php

<?php

namespace model;

final class MemberMapper extends \controllerframework\members\MemberMapper
{
}

The framework derives the mapper for \model\Member as \model\MemberMapper.

8.3 Member_RGLR.php

<?php

namespace model;

final class Member_RGLR
    extends \controllerframework\members\MemberTypeImplementation
{
    public function getYearlyParticipationFee(Member $member): int
    {
        return 0;
    }
}

The classification value RGLR causes the framework to look for \model\Member_RGLR. For this minimal example, the membership fee is irrelevant, so the implementation returns zero.

9. Create the Commands

9.1 LoginCommand

The login screen is public, so it uses NoLoginRequired. It performs the credential check only when the form is submitted.

<?php

namespace commands;

use controllerframework\controllers\Command;
use controllerframework\registry\Request;
use controllerframework\sessions\NoLoginRequired;

final class LoginCommand extends Command
{
    public function doExecute(Request $request): int
    {
        $this->reg->getLoginManager();

        $passwordIsValid = true;

        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            if (!$this->validateCsrfToken($request)) {
                $request->set('errorcode', 'InvalidCsrfToken');
                return self::CMD_ERROR;
            }

            $username = trim((string) $request->get('username'));
            $password = (string) $request->get('password');

            $memberId = $this->reg->getLoginManager()
                ->validateUsername($username);

            if ($memberId !== false) {
                $passwordIsValid = $this->reg->getLoginManager()
                    ->validatePassword((int) $memberId, $password);
            } else {
                $passwordIsValid = false;
            }

            if ($passwordIsValid) {
                $keepLoggedIn =
                    $request->get('rememberMe') === 'Y';

                $this->reg->getLoginManager()->login(
                    (int) $memberId,
                    $keepLoggedIn
                );

                return self::CMD_OK;
            }
        }

        $request->set('csrf_token', $this->getCsrfToken());
        $request->set('passwordIsValid', $passwordIsValid);

        return self::CMD_DEFAULT;
    }

    protected function getLevelOfLoginRequired(): void
    {
        $this->setLoginLevel(new NoLoginRequired());
    }
}
Notice the separation: execute() belongs to the framework and checks the configured login level. Your application implements only doExecute() and getLevelOfLoginRequired().

9.2 WelcomeCommand

This is the first genuinely protected application screen.

<?php

namespace commands;

use controllerframework\controllers\Command;
use controllerframework\registry\Request;
use controllerframework\sessions\User;
use controllerframework\sessions\UserLogin;

final class WelcomeCommand extends Command
{
    public function doExecute(Request $request): int
    {
        $user = User::getInstance();

        if ($user === null) {
            return self::CMD_ERROR;
        }

        $request->set('name', (string) $user->name);

        return self::CMD_OK;
    }

    protected function getLevelOfLoginRequired(): void
    {
        $this->setLoginLevel(new UserLogin());
    }
}

The important line is:

$this->setLoginLevel(new UserLogin());

From that moment on, the framework itself protects the command. The command does not need to implement its own session checks.

UserLogin requires an active member and uses a 60-minute inactivity period for a normal login. If remember-me authentication is used, Release 30 deliberately lets the persistent remember token determine the session duration rather than applying that inactivity timeout.

9.3 Logout

No client Command is required. Release 30 already provides:

\controllerframework\commands\login\LogoutCommand

It calls LoginManager::logout(), removes the session and remember-me tokens, and can then forward to the login path.

10. Connect everything in controls.xml

Now map the paths to Commands and renderers:

<?xml version="1.0" encoding="UTF-8"?>
<control>

    <command path="/" class="\commands\LoginCommand">
        <view name="/loginView" />

        <status value="CMD_OK">
            <forward path="/welcome" />
        </status>

        <status value="CMD_ERROR">
            <view name="/loginView" />
        </status>
    </command>

    <command path="/welcome" class="\commands\WelcomeCommand">
        <view name="/welcomeView" />

        <status value="CMD_ERROR">
            <view name="/loginView" />
        </status>
    </command>

    <command path="/logout"
        class="\controllerframework\commands\login\LogoutCommand">
        <status value="CMD_OK">
            <forward path="/" />
        </status>
    </command>

</control>

There are now exactly three application paths:

PathCommandLoginResult
/LoginCommandNoneLogin view or forward to /welcome
/welcomeWelcomeCommandUserLoginWelcome view
/logoutFramework LogoutCommandNoneLogout and forward to /

11. Create the login and welcome views

11.1 Login view

The login form contains the CSRF token supplied by the Command:

<form method="post" action="/">
    <input
        type="hidden"
        name="csrf_token"
        value="<?= htmlspecialchars(
            (string) $request->get('csrf_token'),
            ENT_QUOTES,
            'UTF-8'
        ) ?>"
    >

    <label for="username">Email address</label>
    <input type="email" name="username" id="username" required>

    <label for="password">Password</label>
    <input type="password" name="password" id="password" required>

    <label>
        <input type="checkbox" name="rememberMe" value="Y">
        Remember me
    </label>

    <button type="submit">Log in</button>
</form>

The complete example also displays a generic error when the credentials are invalid.

11.2 Welcome view

The Command puts the authenticated user's name into the Request:

$request->set('name', (string) $user->name);

The view then displays it safely:

<h1>
    Hi <?= htmlspecialchars(
        (string) $request->get('name'),
        ENT_QUOTES,
        'UTF-8'
    ) ?>,
    welcome to our login protected environment.
</h1>

<form method="get" action="/logout">
    <button type="submit">Logout</button>
</form>

The result for the demo user is:

Hi Demo User, welcome to our login protected environment.

12. Run and test the application

  1. Create the database and execute DatabaseSetup.sql.
  2. Enter the database credentials in config/app_options.ini.
  3. Set _APPDIR to the real HTTPS application URL.
  4. Run composer install.
  5. Make sure assets/logging/ is writable by the web server.
  6. Open the application root in a browser.

Test 1 — Login page

You should see the login form at /.

Test 2 — Invalid password

Enter the correct email and an incorrect password. The same login page should be displayed with a generic authentication error.

Test 3 — Successful login

Use:

Email:    demo@example.com
Password: Demo123!

The framework creates the authenticated session and forwards to /welcome.

Test 4 — Direct access to /welcome

Log out and directly open /welcome. UserLogin rejects the request and the Command infrastructure records the original path. The login view is rendered. After a successful login, the framework's forwarding mechanism can return the user to the original protected path.

Test 5 — Logout

Click Logout. The framework's LogoutCommand destroys the session and removes remember-me tokens, then forwards to /.

13. Understand what happens internally

13.1 First request: GET /

GET /
Controller::run()
InitApplicationController
RenderCompiler
LoginCommand
loginView.php

13.2 POST / with valid credentials

Login form
CSRF validation
validateUsername()
validatePassword()
LoginManager::login()
CMD_OK
/welcome

LoginManager::login() regenerates the session ID, stores the member ID and session state, and optionally creates a 30-day remember-me token.

13.3 GET /welcome

Before WelcomeCommand::doExecute() is allowed to run, the framework invokes the Command's configured UserLogin strategy. That strategy checks the current user and membership state. Only after validation succeeds does the framework execute the Command.

13.4 Rendering

After the Command returns its status, HandleRequestApplicationController asks the corresponding RenderComponentDescriptor for the renderer. For CMD_OK, ViewRenderComponent includes welcomeView.php.

14. Where to go from here

Once this minimal application works, the natural next steps are:

  1. Add more protected Commands using UserLogin.
  2. Add administrator screens using AdminLogin.
  3. Move reusable UI elements into view includes.
  4. Add domain objects and Mappers for your own tables.
  5. Use CommandDecorator when integrating reusable application framework Commands.
  6. Add password initiation/change flows using the framework's existing login Commands.
  7. Use DatarequestRenderComponent, downloads, AJAX or forwards where required.
  8. Add CSRF validation to every state-changing POST Command.
  9. Switch to environment=production and harden server configuration before deployment.
Key development rule: do not put authentication checks in every view. Declare the required login strategy in the Command and let the framework's Command::execute() and LoginRequired hierarchy enforce it.
</html>