Components Guide

Learn the DAO, Service, Controller, and Model patterns for building features in Scriptlog.

Home / Documentation / Components

Architecture Pattern

Scriptlog uses a layered architecture for clean separation of concerns:

Request

HTTP request from user

Controller

Handles HTTP logic

Service

Business logic & validation

DAO

Database operations

Database

MySQL/MariaDB

Front controller first: every public and admin request enters through index.php / admin/index.php, is initialized by Bootstrap, and routed by Dispatcher before it reaches a controller - see Architecture.

DAO

Data Access Layer - handles all database operations

Service

Business logic, validation, and orchestration

Controller

HTTP request handling, calls services

Model

Frontend read/query layer that feeds the theme views

DAO (Data Access Object)

DAO Pattern Guidelines

Guideline Description
Single Responsibility Each DAO handles one database table
Prepared Statements Use for all queries to prevent SQL injection
Return Format Return associative arrays or objects
Error Handling Handle exceptions gracefully

Example: PostDao

PHP lib/dao/PostDao.php
<?php

declare(strict_types=1);

namespace Scriptlog\Dao;

defined('SCRIPTLOG') || die("Direct access not permitted");

use Scriptlog\Core\Dao;
use Scriptlog\Core\DbException;
use Scriptlog\Core\LogError;
use Scriptlog\Core\Sanitize;

class PostDao extends Dao
{
    private const ALLOWED_SORT_COLUMNS = ['ID', 'post_date', 'post_title', 'post_modified'];

    private const PUBLISHED_FILTER = "p.post_status = 'publish' AND p.post_visibility = 'public'";

    public function __construct()
    {
        parent::__construct();
    }

    public function findPosts(string $orderBy = 'ID', ?int $author = null, bool $onlyPublished = true): array
    {
        $sortColumn = $this->resolveSortColumn($orderBy);

        $sql = "SELECT p.ID, p.media_id, p.post_author, p.post_date, p.post_modified,
                       p.post_title, p.post_slug, p.post_content, p.post_status,
                       p.post_visibility, p.post_password, p.post_tags, p.post_headlines,
                       p.post_type, p.post_locale, p.passphrase, u.user_login
                FROM tbl_posts AS p
                INNER JOIN tbl_users AS u ON p.post_author = u.ID
                WHERE p.post_type = 'blog'";

        $data = [];

        if (!is_null($author)) {
            $sql .= " AND p.post_author = ?";
            $data[] = $author;
        }

        if ($onlyPublished) {
            $sql .= " AND " . self::PUBLISHED_FILTER;
        }

        $sql .= " ORDER BY p.$sortColumn DESC";

        $this->setSQL($sql);

        $posts = $this->findAll($data);

        return (empty($posts)) ? [] : $posts;
    }

    public function findPost(int $ID, Sanitize $sanitize, ?int $author = null, bool $onlyPublished = true): ?array
    {
        $idsanitized = $this->filteringId($sanitize, (string)$ID, 'sql');

        $sql = "SELECT ID, media_id, post_author, post_date, post_modified,
                       post_title, post_slug, post_content, post_summary,
                       post_status, post_visibility, post_password, post_tags,
                       post_headlines, post_locale, comment_status, passphrase
                FROM tbl_posts
                WHERE ID = ? AND post_type = 'blog'";

        $data = [$idsanitized];

        if (!is_null($author)) {
            $sql .= " AND post_author = ?";
            $data[] = $author;
        }

        if ($onlyPublished) {
            $sql .= " AND post_status = 'publish' AND post_visibility = 'public'";
        }

        $this->setSQL($sql);

        $postDetail = $this->findRow($data);

        return (empty($postDetail)) ? null : $postDetail;
    }

    public function createPost(array $bind, $topicId): int
    {
        $data = [
            'post_author' => $bind['post_author'],
            'post_date' => $bind['post_date'],
            'post_title' => $bind['post_title'],
            'post_slug' => $bind['post_slug'],
            'post_content' => $bind['post_content'],
            'post_summary' => $bind['post_summary'],
            'post_status' => $bind['post_status'],
            'post_visibility' => $bind['post_visibility'],
            'post_password' => $bind['post_password'],
            'post_tags' => $bind['post_tags'],
            'post_headlines' => $bind['post_headlines'],
            'post_locale' => $bind['post_locale'] ?? 'en',
            'comment_status' => $bind['comment_status'],
            'passphrase' => $bind['passphrase']
        ];

        if (!empty($bind['media_id'])) {
            $data['media_id'] = $bind['media_id'];
        }

        $this->create("tbl_posts", $data);

        $postId = (int)$this->lastId();

        foreach ((array)$topicId as $topic_id) {
            $this->create("tbl_post_topic", [
                'post_id' => $postId,
                'topic_id' => $topic_id
            ]);
        }

        if (function_exists('page_cache_clear')) {
            page_cache_clear();
        }

        return $postId;
    }

    public function deletePost(int $ID, Sanitize $sanitize): void
    {
        $cleanId = $this->filteringId($sanitize, (string)$ID, 'sql');
        $this->deleteRecord("tbl_posts", ['ID' => $cleanId]);

        if (function_exists('page_cache_clear')) {
            page_cache_clear();
        }
    }

    public function anonymizePostAuthor(int $authorId, int $fallbackAuthorId = 1): bool
    {
        $this->modify("tbl_posts", ['post_author' => $fallbackAuthorId], ['post_author' => $authorId]);

        return true;
    }

    public function totalPostRecords(?int $author = null): int
    {
        $sql = "SELECT ID FROM tbl_posts WHERE post_type = 'blog'";

        $data = [];

        if (!is_null($author)) {
            $sql = "SELECT ID FROM tbl_posts WHERE post_author = ? AND post_type = 'blog'";
            $data[] = $author;
        }

        $this->setSQL($sql);

        return $this->checkCountValue($data);
    }

    // Additional paginated, archive, scheduled-publish and API query
    // methods are omitted here for brevity.
}

Service Layer

Service Layer Guidelines

Principle Description
Business Logic Services contain business logic
Validation Services validate input
Data Access Services call DAOs
Composition Services can call other services

Example: PostService

PHP lib/service/PostService.php
<?php

namespace Scriptlog\Service;

defined('SCRIPTLOG') || die("Direct access not permitted");

use Scriptlog\Core\FormValidator;
use Scriptlog\Core\Sanitize;
use Scriptlog\Core\Session;
use Scriptlog\Dao\MediaDao;
use Scriptlog\Dao\PostDao;
use Scriptlog\Dao\TopicDao;

class PostService
{
    private $postId;
    private $post_image;
    private $author;
    private $post_date;
    private $post_modified;
    private $title;
    private $slug;
    private $content;
    private $meta_desc;
    private $post_status;
    private $post_visibility;
    private $post_password;
    private $post_headlines;
    private $comment_status;
    private $passphrase;
    private $topics;
    private $tags;
    private $post_locale;
    private $postDao;
    private $validator;
    private $sanitizer;

    public function __construct(PostDao $postDao, FormValidator $validator, Sanitize $sanitizer)
    {
        $this->postDao = $postDao;
        $this->validator = $validator;
        $this->sanitizer = $sanitizer;
    }

    public function setPostId($postId)
    {
        $this->postId = $postId;
    }

    public function setPostTitle($title)
    {
        $this->title = prevent_injection($title);
    }

    public function setPostSlug($slug)
    {
        $this->slug = make_slug($slug);
    }

    public function setPostContent($content, $skipPurify = false)
    {
        $this->content = $skipPurify ? $content : purify_dirty_html($content);
    }

    public function setPassPhrase($passphrase)
    {
        $this->passphrase = hash('sha256', app_key() . $passphrase);
    }

    public function setPostLocale($post_locale)
    {
        $this->post_locale = sanitize_locale($post_locale);
    }

    // ... other fluent setters (image, author, dates, status, tags, topics) omitted

    public function grabPosts($orderBy = 'ID', $author = null)
    {
        return $this->postDao->findPosts($orderBy, $author, false);
    }

    public function grabPost($postId)
    {
        return $this->postDao->findPost($postId, $this->sanitizer, null, false);
    }

    public function addPost()
    {
        $category = new TopicDao();

        $this->validator->sanitize($this->author, 'int');
        $this->validator->sanitize($this->post_image, 'int');
        $this->validator->sanitize($this->title, 'string');

        if ((!empty($this->meta_desc)) || (!empty($this->tags))) {
            $this->validator->sanitize($this->meta_desc, 'string');
        }

        $topic_id = $this->topics;

        if ($this->topics == 0) {
            $categoryId = $category->createTopic(['topic_title' => 'Uncategorized', 'topic_slug' => 'uncategorized']);
            $getCategory = $category->findTopicById($categoryId, $this->sanitizer, \PDO::FETCH_ASSOC);
            $topic_id = isset($getCategory['ID']) ? abs((int)$getCategory['ID']) : 0;
        }

        $new_post = [
            'media_id' => $this->post_image,
            'post_author' => $this->author,
            'post_date' => $this->post_date,
            'post_title' => $this->title,
            'post_slug' => $this->slug,
            'post_content' => $this->content,
            'post_summary' => $this->meta_desc,
            'post_status' => $this->post_status,
            'post_visibility' => $this->post_visibility,
            'post_password' => $this->post_password,
            'post_tags' => $this->tags,
            'post_headlines' => $this->post_headlines,
            'post_locale' => $this->post_locale ?? 'en',
            'comment_status' => $this->comment_status,
            'passphrase' => $this->passphrase
        ];

        return $this->postDao->createPost($new_post, $topic_id);
    }

    public function modifyPost()
    {
        $this->validator->sanitize($this->postId, 'int');
        $this->validator->sanitize($this->author, 'int');
        $this->validator->sanitize($this->post_image, 'int');
        $this->validator->sanitize($this->title, 'string');

        $postData = [
            'post_author' => $this->author,
            'post_modified' => $this->post_modified,
            'post_title' => $this->title,
            'post_slug' => $this->slug,
            'post_content' => $this->content,
            'post_summary' => $this->meta_desc,
            'post_status' => $this->post_status,
            'post_visibility' => $this->post_visibility,
            'post_password' => $this->post_password,
            'post_tags' => $this->tags,
            'post_headlines' => $this->post_headlines,
            'post_locale' => $this->post_locale ?? 'en',
            'comment_status' => $this->comment_status,
            'passphrase' => $this->passphrase
        ];

        if (!empty($this->post_image)) {
            $postData['media_id'] = $this->post_image;
        }

        if (!empty($this->post_date)) {
            $postData['post_date'] = $this->post_date;
        }

        $this->postDao->updatePost($this->sanitizer, $postData, $this->postId, $this->topics);
    }

    public function removePost()
    {
        $this->validator->sanitize($this->postId, 'int');

        $data_post = $this->postDao->findPost($this->postId, $this->sanitizer);
        if (!$data_post) {
            $_SESSION['error'] = "postNotFound";
            direct_page('index.php?load=posts&error=postNotFound', 404);
            return false;
        }

        $media_id = $data_post['media_id'] ?? 0;

        // Delete the featured image (and its large/medium/small variants),
        // then remove the post record.
        if (class_exists('MediaDao')) {
            $medialib = new MediaDao();
            if (method_exists($medialib, 'findMediaBlog') && $media_id) {
                $medialib->deleteMedia((int)$media_id, $this->sanitizer);
            }
        }

        $this->postDao->deletePost($this->postId, $this->sanitizer);
    }

    public function postAuthorId()
    {
        if (isset(Session::getInstance()->scriptlog_session_id)) {
            return Session::getInstance()->scriptlog_session_id;
        }

        return false;
    }

    public function postAuthorLevel()
    {
        return user_privilege();
    }

    public function totalPosts(array $data = []): ?int
    {
        $author = isset($data[0]) ? (int)$data[0] : null;

        return $this->postDao->totalPostRecords($author);
    }

    // Paginated/archive/API helpers (getPublishedPostsApi, searchPostsApi, ...)
    // are also part of this service and omitted here for brevity.
}

Controller

Controller Guidelines

Guideline Description
HTTP Handling Controllers handle HTTP requests
Service Calls Controllers call services
Response Format Controllers return views or JSON
Thin Design Keep controllers thin, move logic to services

Example: PostController

PHP lib/controller/PostController.php
<?php

namespace Scriptlog\Controller;

defined('SCRIPTLOG') || die("Direct access not permitted");

use Scriptlog\Core\ActionConst;
use Scriptlog\Core\AppException;
use Scriptlog\Core\BaseApp;
use Scriptlog\Core\LogError;
use Scriptlog\Core\View;
use Scriptlog\Dao\MediaDao;
use Scriptlog\Dao\TopicDao;
use Scriptlog\Dto\PostRequestDto;
use Scriptlog\Dto\UploadedFileDto;
use Scriptlog\Service\PostApplicationService;
use Scriptlog\Service\PostService;
use Scriptlog\Validator\FileUploadValidator;
use Scriptlog\Validator\PostValidator;
use Scriptlog\Validator\ProtectedPostValidator;

class PostController extends BaseApp
{
    private $view;
    private $postService;
    private $topicDao;
    private $mediaDao;
    private $appService;

    public function __construct(PostService $postService, TopicDao $topicDao, MediaDao $mediaDao, PostApplicationService $appService)
    {
        $this->postService = $postService;
        $this->topicDao = $topicDao;
        $this->mediaDao = $mediaDao;
        $this->appService = $appService;
    }

    public function listItems()
    {
        $errors = array();
        $status = array();
        $checkError = true;
        $checkStatus = false;

        if (isset($_SESSION['error'])) {
            $checkError = false;
            ($_SESSION['error'] == 'postNotFound') ? array_push($errors, "Error: Post Not Found!") : "";
            unset($_SESSION['error']);
        }

        if (isset($_SESSION['status'])) {
            $checkStatus = true;
            ($_SESSION['status'] == 'postAdded') ? array_push($status, "New post added") : "";
            ($_SESSION['status'] == 'postUpdated') ? array_push($status, "Post updated") : "";
            ($_SESSION['status'] == 'postDeleted') ? array_push($status, "Post deleted") : "";
            unset($_SESSION['status']);
        }

        $this->setView('all-posts');
        $this->setPageTitle('Posts');
        $this->view->set('pageTitle', $this->getPageTitle());

        if (!$checkError) {
            $this->view->set('errors', $errors);
        }

        if ($checkStatus) {
            $this->view->set('status', $status);
        }

        if ($this->postService->postAuthorLevel() == 'administrator') {
            $this->view->set('postsTotal', $this->postService->totalPosts());
            $this->view->set('posts', $this->postService->grabPosts());
        } else {
            $this->view->set('postsTotal', $this->postService->totalPosts([$this->postService->postAuthorId()]));
            $this->view->set('posts', $this->postService->grabPosts('ID', $this->postService->postAuthorId()));
        }

        return $this->view->render();
    }

    public function insert()
    {
        $errors = array();
        $checkError = true;
        $user_level = $this->postService->postAuthorLevel();
        $topics = $this->topicDao;
        $medialib = $this->mediaDao;

        if (isset($_POST['postFormSubmit'])) {
            $mediaFile = UploadedFileDto::fromGlobals();
            $file_location = $mediaFile->tmpName;
            $file_type = $mediaFile->type;
            $file_name = $mediaFile->name;
            $file_size = $mediaFile->size;
            $file_error = $mediaFile->error;

            $new_filename = generate_filename($file_name)['new_filename'];
            $file_extension = generate_filename($file_name)['file_extension'];

            try {
                $this->checkPostCsrf();
                $this->checkPostPayload();

                $checkError = $this->validatePostSubmission($file_location, $file_error, $file_size, $file_name, $errors, $checkError);

                if (!$checkError) {
                    $this->renderNewPostForm($errors, $_POST, $topics, $medialib, $user_level);
                    return $this->view->render();
                }

                $this->appService->createPost($file_location, $file_type, $file_name, $file_size, $file_extension, $new_filename, $user_level);

                $_SESSION['status'] = "postAdded";
                direct_page('index.php?load=posts&status=postAdded', 200);
            } catch (\Throwable $th) {
                LogError::setStatusCode(http_response_code());
                LogError::exceptionHandler($th);
            }
        }

        $this->renderNewPostForm(null, null, $topics, $medialib, $user_level);
        return $this->view->render();
    }

    public function remove($id)
    {
        $id = abs((int)$id);

        if ($id <= 0) {
            $_SESSION['error'] = "postNotFound";
            direct_page('index.php?load=posts&error=postNotFound', 404);
            return;
        }

        $getPost = $this->postService->grabPost($id);
        if (!$getPost) {
            $_SESSION['error'] = "postNotFound";
            direct_page('index.php?load=posts&error=postNotFound', 404);
            return;
        }

        try {
            $this->postService->setPostId($id);
            $this->postService->removePost();
            $_SESSION['status'] = "postDeleted";
            direct_page('index.php?load=posts&status=postDeleted', 200);
        } catch (\Throwable $th) {
            LogError::setStatusCode(http_response_code());
            LogError::exceptionHandler($th);
        }
    }

    // ─── Security ──────────────────────────────────────────────

    private function checkPostCsrf()
    {
        if (!csrf_check_token('csrfToken', $_POST, 60 * 10)) {
            header(($_SERVER["SERVER_PROTOCOL"] ?? "HTTP/1.1") . MESSAGE_BADREQUEST, true, 400);
            header('Status: 400 Bad Request');
            throw new AppException(MESSAGE_UNPLEASANT_ATTEMPT);
        }
    }

    // ─── Validation ───────────────────────────────────────────

    private function validatePostSubmission($file_location, $file_error, $file_size, $file_name, &$errors, $checkError)
    {
        $dto = PostRequestDto::fromGlobals();
        $result = (new PostValidator())->validate($dto);
        if (!$result->isValid()) {
            $checkError = false;
            $errors = array_merge($errors, $result->getErrors());
        }

        $uploadedFile = UploadedFileDto::fromGlobals();
        if ($uploadedFile->isValid()) {
            $fileResult = (new FileUploadValidator())->validate($uploadedFile);
            if (!$fileResult->isValid()) {
                $checkError = false;
                $errors = array_merge($errors, $fileResult->getErrors());
            }
        }

        if ($dto->isProtected()) {
            $pwdResult = (new ProtectedPostValidator())->validate($dto);
            if (!$pwdResult->isValid()) {
                $checkError = false;
                $errors = array_merge($errors, $pwdResult->getErrors());
            }
        }

        return $checkError;
    }

    protected function setView($viewName)
    {
        $this->view = new View('admin', 'ui', 'posts', $viewName);
    }

    // update(), renderNewPostForm(), renderEditPostForm() and the remaining
    // payload checks are omitted here for brevity.
}

Model

Model Guidelines

Principle Description
Frontend Only Models live in lib/model/ and serve the public (frontend) theme, not the admin panel
Read / Query Each model extends BaseModel and exposes focused read methods (e.g. getLatestPosts(), getPostById()) built on shared setSQL() / findAll() / findRow() helpers
View Preparation Models return ready-to-render rows (joined with authors, media and categories) so controllers and themes stay thin

Example: PostModel

PHP lib/model/PostModel.php
<?php

namespace Scriptlog\Model;

defined('SCRIPTLOG') || die("Direct access not permitted");

use Scriptlog\Core\BaseModel;
use Scriptlog\Core\Paginator;
use Scriptlog\Core\Sanitize;

class PostModel extends BaseModel
{
    private $linkPosts;

    public function getPostFeeds($limit)
    {
        $sql = "SELECT p.ID, p.media_id, p.post_author,
                  p.post_date, p.post_modified, p.post_title,
                  p.post_slug, p.post_content, p.post_type,
                  p.post_status, p.post_tags,
                  p.post_sticky, u.user_fullname, u.user_login
            FROM tbl_posts AS p
            INNER JOIN tbl_users AS u ON p.post_author = u.ID
            WHERE p.post_type = 'blog' AND p.post_status = 'publish'
            AND p.post_visibility = 'public'
            ORDER BY p.ID DESC LIMIT :limit";

        $data = array(':limit' => $limit);

        $this->setSQL($sql);

        $feeds = $this->findAll($data);

        return (empty($feeds)) ?: $feeds;
    }

    public function getLatestPosts($limit)
    {
        $sql = "SELECT p.ID, p.media_id, p.post_author,
            p.post_date AS created_at, p.post_modified AS modified_at,
            p.post_title, p.post_slug, p.post_content, p.post_summary,
            p.post_keyword, p.post_status, p.post_tags,
            p.post_type, p.comment_status,
            m.media_filename, m.media_caption, m.media_access,
            u.user_fullname, u.user_login,
            (SELECT COUNT(c.ID) FROM " . $this->table('tbl_comments') . " c WHERE c.comment_post_id = p.ID AND c.comment_status = 'approved') AS total_comments,
            (SELECT GROUP_CONCAT(CONCAT(t.ID, ':', t.topic_title, ':', t.topic_slug) SEPARATOR '|')
             FROM " . $this->table('tbl_post_topic') . " pt
             JOIN " . $this->table('tbl_topics') . " t ON pt.topic_id = t.ID
             WHERE pt.post_id = p.ID AND t.topic_status = 'Y') AS topics_data
            FROM " . $this->table('tbl_posts') . " AS p
            LEFT JOIN " . $this->table('tbl_media') . " AS m ON p.media_id = m.ID
                AND m.media_target = 'blog'
                AND m.media_access = 'public'
                AND m.media_status = '1'
            INNER JOIN " . $this->table('tbl_users') . " AS u ON p.post_author = u.ID
            WHERE p.post_status = 'publish'
            AND p.post_type = 'blog'
            AND p.post_visibility = 'public'
            AND u.user_banned = '0'
            ORDER BY p.post_date DESC LIMIT :limit";

        $this->setSQL($sql);

        $latestPosts = $this->findAll([':limit' => $limit]);

        return (empty($latestPosts)) ?: $latestPosts;
    }

    public function getPostById($id)
    {
        $sql = "SELECT p.ID, p.media_id, p.post_author, p.post_date, p.post_modified, p.post_title,
          p.post_slug, p.post_content, p.post_summary, p.post_keyword, p.post_status, p.post_sticky,
          p.post_type, p.post_visibility, p.post_password, p.comment_status AS comment_permit,
          m.media_filename, m.media_caption, m.media_target,
          m.media_access, m.media_status, u.user_login, u.user_fullname
          FROM tbl_posts p
          LEFT JOIN tbl_media m ON p.media_id = m.ID AND m.media_target = 'blog' AND m.media_access = 'public' AND m.media_status = '1'
          LEFT JOIN tbl_users u ON p.post_author = u.ID
          WHERE p.ID = :ID
          AND p.post_status = 'publish'
          AND p.post_type = 'blog'";

        $sanitizeid = Sanitize::severeSanitizer($id);
        $this->setSQL($sql);
        $item = $this->findRow([':ID' => $sanitizeid]);

        return (empty($item)) ?: $item;
    }

    // getPostBySlug(), getAllBlogPosts(), getPostByAuthor(), getRandomHeadlines(),
    // getRelatedPosts(), getRandomPosts() and getPostsOnSidebar() omitted for brevity.
}

Utility Functions

Utility functions are loaded via lib/utility-loader.php:

Category Functions
Security csrf-defender.php, remove-xss.php, form-security.php
Validation email-validation.php, url-validation.php
Plugins plugin-helper.php, plugin-validator.php, invoke-plugin.php
Formatting escape-html.php, limit-word.php
Media invoke-frontimg.php, upload-video.php
Session turn-on-session.php, regenerate-session.php

Image Handling Functions

Function Description Location
invoke_webp_image() Returns WebP URL if available, else original lib/utility/invoke-webp-image.php
invoke_frontimg() Primary function for displaying featured images lib/utility/invoke-frontimg.php
invoke_responsive_image() Generates <picture> element with WebP lib/utility/invoke-responsive-image.php
invoke_hero_image() Hero images with fetchpriority="high" lib/utility/invoke-responsive-image.php
invoke_gallery_image() Gallery images with lazy loading lib/utility/invoke-responsive-image.php

Access Control

All admin pages must implement proper authorization checks:

PHP Authorization Check
// In admin pages, check authorization before processing
if (false === $authenticator->userAccessControl(ActionConst::PRIVACY)) {
    direct_page('index.php?load=403&forbidden=' . forbidden_id(), 403);
}

Action Constants & Required Levels

Action Required Level
ActionConst::PRIVACY, WRITING administrator
ActionConst::USERS administrator
ActionConst::IMPORT administrator
ActionConst::PLUGINS, THEMES, CONFIGURATION administrator, manager
ActionConst::PAGES, NAVIGATION administrator, manager
ActionConst::TOPICS administrator, manager, editor
ActionConst::COMMENTS, MEDIALIB, REPLY administrator, manager, author
ActionConst::POSTS administrator, manager, editor, author, contributor
ActionConst::DASHBOARD (default) any authenticated role, incl. subscriber

Security Considerations

Always use prepared statements
Sanitize all user input
Validate data before processing
Check authorization before actions
Use CSRF tokens on all forms
Log errors securely (no secrets)