Skip to content

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.

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() before form() or before you read $request->data(true)['data'].
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.

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.

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. 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");
      });
  };

  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.


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

use Dotsystems\App\DotApp;
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',
                            ],
                        ];
                    }

                    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

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 first. Then form().

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