Zum Inhalt springen

AI blog · DotApp PHP Framework 2.0

How to create secure forms in DotApp PHP Framework

A Shop contact form is several fields plus Submit. That is a fo-rm plus {{ formName(saveContact) }} between the tags, the generated /assets/dotapp/dotapp.js, then PHP crcCheck() and $request->form(['POST'], 'saveContact', ok, err). This article is a complete copy-paste: view, module JS, GET+POST routes, and controller. Why the channel exists: How DotApp PHP Framework protects the browser-to-PHP channel. How Request returns data: protected vs original. Dedicated article: Request lifecycle in DotApp PHP Framework. crcCheck() once — a second call on the same request burns the token: Request lifecycle.

Request data: protected vs original

Must: ask for original values

Incoming GET/POST is auto-protected so the programmer is not one forgotten sanitizer away from XSS / injection-era bugs. Request has an explicit switch:

$request->data() (false / omitted) — protected, escaped copy. Safe to print into HTML.
$request->data(true)original values as the client sent them.

After a secure-channel unwrap, fields live under $request->data(true)['data']. Passwords, decrypt, hashes, CRC payloads, HTML you intend to store, search text with %, and any compare must use original data. Characters such as ), =, %, quotes, and & are rewritten in the protected copy. Hashing that copy is hashing a different string. DotApp::DotApp()->unprotect($variable) still exists for a value you already hold (by reference). Prefer data(true) on the request. Login passwords: Authentication and 2FA. Canonical map: Request lifecycle.

Common mistakes

Wrong Right
{{ formName(saveContact) }} before <fo-rm> or after </fo-rm>. Place it as a child between the opening and closing fo-rm tags. Outside that pair the renderer leaves the tag unchanged (silent failure).
A plain <form> with a lone CSRF hidden field. <fo-rm method="POST" …>, formName inside, generated dotapp.js, then crcCheck().
Skip crcCheck() “just for now”. Always crcCheck() once before form() or before you read $request->data(true)['data'].
Global middleware / Router::before calls crcCheck(), then the save method calls it again. One call per request. A passing check burns the one-time token. Second call is false (HTTP 400). Request lifecycle.
Put data-dotapp-nojs on the form so it “submits normally”. Leave the hijack in place. Rebuild the whole chain only if you truly leave the channel — you almost never should.
Wrap row clicks (toggle, delete, pager, drag-and-drop) in fo-rm. Those are type="button" plus encrypted data-* plus $dotapp().load(). One add/edit fo-rm above the table is enough.
Invent a tag named f-form. The tag is fo-rm. Nothing else.
Same extra key on two identifier fields; or location.reload() after a successful stay-on-page save. Unique $key2 per field. Patch the DOM (and toast). Use redirectTo only when leaving the page.
$request->data()['data']['password'] (or any secret) from the protected copy. $request->data(true)['data']. See Request data.
Only .after(); HTTP 400 “Bad request” shows nothing. Hook .onError(), parseReply the error body, unstick loaders. 400/403 never reach .after().

When to use fo-rm

Use <fo-rm> only when the user fills several fields and submits: Shop contact, profile, login, “save item”. A single click is not a form. Clicks, toggles, deletes, pagination, filters, and reorder use $dotapp().load() — see Secure backend-frontend communication. Files and ZIP archives use uploadFile, never a file input inside fo-rm (CRC cannot wrap a file).

Must: formName inside fo-rm

{{ formName(saveContact) }} is a child of fo-rm. The opening tag needs method. The string saveContact must match PHP form(..., 'saveContact', ...). Omit the directive and the tag is left in the HTML unchanged — the post will not bind.

Complete files for Shop Contact


app/modules/Shop/
  module.init.php                 GET + POST /shop/contact
  Controllers/Contact.php
  views/contact.view.php
  assets/js/contact.js            → /assets/modules/Shop/js/contact.js
    

Scaffold the module first: How to create a module in DotApp PHP Framework. Load /assets/dotapp/dotapp.js before contact.js. That URL is generated per client — not a static copy. It carries the per-session piece of the working key. The rest is app.c_enc_key, extra keys, and key update — the framework compounds them before the post is sealed. The stack in words: How DotApp PHP Framework protects the browser-to-PHP channel.

Complete view

File: app/modules/Shop/views/contact.view.php. formName sits between the fo-rm tags. The script order is framework first, Shop second.


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>{{ var: $title }}</title>
</head>
<body>
  <div id="status" hide="hide"></div>
  <div id="error-message" hide="hide"></div>

  <fo-rm method="POST" id="contactForm" action="{{ var: $postAction }}">
    <input type="text" name="email" autocomplete="username" required />
    <input type="text" name="message" required />

    {{ formName(saveContact) }}

    <button type="submit" id="contactBtn">Send</button>
  </fo-rm>

  <script src="/assets/dotapp/dotapp.js"></script>
  <script src="/assets/modules/Shop/js/contact.js"></script>
</body>
</html>
    

Complete module JS

File: app/modules/Shop/assets/js/contact.js. Boot on the dotapp event (or run immediately if window.$dotapp already exists). Bind .form(), halt a second submit, set loading / loader, then parseReply on success and on HTTP 400/403 (.onError()). This contact page stays put: patch a status node. Do not location.reload(). If a later screen must leave (login, wizard), read reply.redirectTo and assign window.location only then. Client API: How to use $dotapp() JavaScript.


(function () {
  var runMe = function ($dotapp) {
    $dotapp()
      .form("#contactForm")
      .before(function (data, form) {
        if ($dotapp(form).attr("blocked") == 1) {
          return $dotapp().halt();
        }
        $dotapp(form).attr("blocked", "1");
        $dotapp("#contactBtn").attr("loading", "true").attr("loader", "dots");
        $dotapp("#error-message").attr("hide", "hide");
        $dotapp("#status").attr("hide", "hide");
      })
      .after(function (data, response, form) {
        var reply = $dotapp().parseReply(response);
        if (reply && reply.status == 1) {
          if (reply.html) $dotapp("#contactWrap").html(reply.html);
          if (reply.message) $dotapp("#status").attr("hide", "false").html(reply.message);
          if (reply.redirectTo) {
            window.location = reply.redirectTo;
            return;
          }
        } else if (reply && reply.message) {
          $dotapp("#error-message").attr("hide", "false").html(reply.message);
        }
        $dotapp(form).attr("blocked", "0");
        $dotapp("#contactBtn").removeAttr("loading").removeAttr("loader");
      })
      .onError(function (data, status, error, form) {
        var reply = $dotapp().parseReply(error);
        var msg = (reply && typeof reply === "object" && reply.message) ? reply.message : "Request failed";
        $dotapp("#error-message").attr("hide", "false").html(msg);
        $dotapp(form).attr("blocked", "0");
        $dotapp("#contactBtn").removeAttr("loading").removeAttr("loader");
      });
  };

  if (window.$dotapp) runMe(window.$dotapp);
  else window.addEventListener("dotapp", function () {
    runMe(window.$dotapp);
  }, { once: true });
})();
    

On submit the generated script converts fo-rm, adds CRC plus transport CSRF, and POSTs with header dotapp: load. An empty .after() after a successful save is a bug: the database changed and the page did not.

GET and POST routes

Register both verbs in Shop initialize(). GET renders the page. POST is the channel endpoint.


<?php
$p = Config::module('Shop', 'prefix');
Router::get($p . '/contact', 'Shop:Contact@page!', Router::STATIC_ROUTE);
Router::post($p . '/contact', 'Shop:Contact@save!', Router::STATIC_ROUTE);
    

Complete controller

File: app/modules/Shop/Controllers/Contact.php. Default $answer is a 400. After a passing crcCheck(), form() runs the ok callback only when the bound name, URL, and method match. The err callback is for a failed signature. Always finish with ajaxReply. Field values live under $request->data(true)['data'] after unwrap. Use data(true) here, not the escaped data() copy. Persist with named RAW: INSERT … VALUES (:email, :message) plus an array. Do not concatenate $email into SQL. Create shop_contacts in Installation.php like any other shop_* table.


<?php
namespace Dotsystems\App\Modules\Shop\Controllers;

use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\DB;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Renderer;
use Dotsystems\App\Parts\Validator;

class Contact extends \Dotsystems\App\Parts\Controller
{
    public static function page($request)
    {
        return Renderer::new()->module('Shop')
            ->setView('contact')
            ->setViewVar('title', 'Contact')
            ->setViewVar('postAction', '/shop/contact')
            ->renderView();
    }

    public static function save($request)
    {
        $answer = ['code' => 400, 'body' => ['status' => 0, 'message' => 'Bad request']];

        if ($request->crcCheck()) {
            $answer = $request->form(
                ['POST'],
                'saveContact',
                function ($request) {
                    $data = $request->data(true)['data'] ?? [];
                    $email = $data['email'] ?? '';
                    $message = $data['message'] ?? '';

                    if (!Validator::isEmail($email) || $message === '') {
                        return [
                            'code' => 200,
                            'body' => [
                                'status' => 0,
                                'errorNo' => 1,
                                'message' => 'Enter a valid email and a message',
                            ],
                        ];
                    }

                    $saved = false;
                    DB::module('RAW')->q(function ($qb) use ($email, $message) {
                        $qb->raw(
                            'INSERT INTO shop_contacts (email, message, created_at)
                             VALUES (:email, :message, :created_at)',
                            [
                                'email' => $email,
                                'message' => $message,
                                'created_at' => date('Y-m-d H:i:s'),
                            ]
                        );
                    })->execute(
                        function () use (&$saved) { $saved = true; },
                        function ($error) { Logger::use()->error('shop_contacts insert failed', $error); }
                    );
                    if (!$saved) {
                        return [
                            'code' => 500,
                            'body' => ['status' => 0, 'message' => 'Save failed'],
                        ];
                    }

                    return [
                        'code' => 200,
                        'body' => [
                            'status' => 1,
                            'message' => 'Saved',
                        ],
                    ];
                },
                function ($request) {
                    return [
                        'code' => 400,
                        'body' => ['status' => 0, 'message' => 'Invalid form'],
                    ];
                }
            );
        }

        return DotApp::DotApp()->ajaxReply($answer['body'], $answer['code']);
    }
}
    
Call Use
crcCheck() Integrity of the posted { data, crc }. Fail → do not read fields.
form(['POST'], 'saveContact', ok, err) ok if handler + URL + method match. Fields: $request->data(true)['data'].
ajaxReply($body, $code) Base64 JSON for parseReply. Not Response::json.

Must: unique extra key per identifier

Contact above has no primary keys. The moment you add ids (assignee, product, ticket), encrypt each field with its own extra key. Decrypt with the same string. Reject false. Then Auth::can() / ownership — encryption is not authorization.


<select name="userid">
  <option value="{{ enc(Shop.user.id): $u.id }}">{{ var: $u.name }}</option>
</select>
<select name="productid">
  <option value="{{ enc(Shop.product.id): $p.id }}">{{ var: $p.title }}</option>
</select>
    

<?php
$uid = Crypto::decrypt($data['userid'] ?? '', 'Shop.user.id');
$pid = Crypto::decrypt($data['productid'] ?? '', 'Shop.product.id');
if ($uid === false || $pid === false) {
    return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Bad id']];
}
if (!Auth::can('Shop.users.edit')) {
    return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Forbidden']];
}
    

Must not wrap row clicks in fo-rm

Five forms in one table row (up, down, toggle, active, delete) is wrong. Keep at most one add/edit fo-rm on the page. Row actions are buttons plus data-item="{{ enc(Shop.item.id): $item.id }}" plus load(). Lists, overlay, and pager: How to build AJAX lists with pagination.

FAQ

data() or data(true)?

data() is the auto-protected copy (print it). data(true) is the original payload. Channel fields: $request->data(true)['data']. Passwords and special characters must use original data — see Request data.

Why did HTTP 400 show nothing?

$dotapp().load() treats 400/403/404/429 as errors. That path is .onError(), not .after(). The body is still ajaxReplyparseReply it, show message, unstick the button.

What does “silent failure” mean for formName?

If the directive is not a child of fo-rm, the renderer leaves it unchanged: no encrypted hidden fields, and PHP will not run the ok callback.

Why not a normal form tag?

Bots and scanners target a static form element. DotApp converts fo-rm at runtime. A plain form plus a lone CSRF field also does not bind handler, URL, and method. That binding is formName.

When is data-dotapp-nojs allowed?

Almost never. It turns off the hijack. You would have to rebuild CRC, CSRF, and binding yourself. Leave the attribute off.

Can I skip crcCheck if form() already checks the signature?

No. crcCheck() is the integrity gate for the posted body. Call it once, then form(). form() does not re-run CRC.

Middleware already crcCheck — why does save still 400?

The first success invalidates the one-time token. The handler’s second crcCheck() is a used-token failure. One call per request. Request lifecycle.

Why not reload the page after Send?

The hijack never does a native submit. location.reload() is a slow, flashing stand-in for a missing DOM patch. Stay on the contact page: show reply.message (and reply.html if you re-render a fragment). redirectTo only when the user should leave (for example after login).

What if I reuse Shop.user.id on a product field?

Then a user ciphertext can decrypt on the product field. Use Shop.product.id there. Unique extra keys stop that mix-up. They still do not replace Auth::can().

ajaxReply or Response::json?

This POST is on the channel: ajaxReply + parseReply. Ordinary JSON HTTP routes that are not this channel use Router and Response::json. Bridge is named PHP functions, not a multi-field form — DotBridge.

See also