Logic File Architecture Documentation

Overview

The logic layer (/logic/) provides the business logic and controller functionality in the application's MVC-like architecture. All logic files follow a standardized pattern using the LogicResult class for consistent return handling.

Critical rule: Logic files must never call exit(), die(), or throw exceptions. Every code path must return a LogicResult object. This makes logic files testable, composable, and safe to call from any context.

Directory Structure

/logic/                     # Core logic files
/plugins/*/logic/          # Plugin-specific logic files
/theme/*/logic/            # Theme-specific logic overrides

Logic File Pattern

Every logic file follows this naming convention and structure:

File naming: [page_name]_logic.php

Function naming: [page_name]_logic(array $input): LogicResult

Every logic file takes a single $input array (the merged $_GET/$_POST/route params from the caller) and returns a LogicResult. There are no extra positional parameters and no per-route variants — the calling convention is identical for page handlers, action surfaces, and API entry points.

Basic Structure

<?php

function page_name_logic(array $input): LogicResult {
    // PathHelper, Globalvars, SessionControl are always available
    require_once(PathHelper::getIncludePath('includes/LibraryFunctions.php'));

    // Include required data classes
    require_once(PathHelper::getIncludePath('data/users_class.php'));

    // Get singletons
    $settings = Globalvars::get_instance();
    $session = SessionControl::get_instance();

    // Business logic here
    $page_vars = array();
    $page_vars['settings'] = $settings;
    $page_vars['session'] = $session;

    // Return using LogicResult
    return LogicResult::render($page_vars);
}
?>

LogicResult Class

The LogicResult class provides a standardized return format for all logic functions, enabling consistent handling of renders, redirects, and errors.

Class Definition

class LogicResult {
    public $redirect = null;
    public $data = [];
    public $error = null;

    // Static factory methods
    public static function redirect($url, $data = []);
    public static function render($data = []);
    public static function error($message, $data = []);
}

Three Return Patterns

1. Render Pattern

Used when the logic prepares data for a view to render:

function product_logic(array $input): LogicResult {
    $product = new Product($input['id'], TRUE);

    $page_vars = array();
    $page_vars['product'] = $product;
    $page_vars['title'] = $product->get('pro_name');

    return LogicResult::render($page_vars);
}

2. Redirect Pattern

Used when the logic needs to redirect to another page:

function logout_logic(array $input): LogicResult {
    $session = SessionControl::get_instance();
    $session->log_out();

    return LogicResult::redirect('/login');
}

3. Error Pattern

Used when an error occurs that should be displayed to the user:

function secure_page_logic(array $input): LogicResult {
    $session = SessionControl::get_instance();

    if (!$session->is_logged_in()) {
        return LogicResult::error('You must be logged in to access this page');
    }

    // Continue with normal logic...
    return LogicResult::render($page_vars);
}

View Integration

Views should always use process_logic() to call logic functions. This handles redirects, errors, and data extraction automatically:

// ✅ CORRECT - Always use process_logic()
$page_vars = process_logic(product_logic(array_merge($_GET, $_POST)));
$product = $page_vars['product'];

process_logic() handles:

  • LogicResult::redirect() — performs the redirect
  • LogicResult::error() — adds error message to session, returns data for re-display
  • LogicResult::render() — returns the data array
  • Legacy array returns — passes through unchanged for backward compatibility
Never manually check LogicResult in views:

// ❌ WRONG - Don't manually handle LogicResult in views
$result = product_logic(array_merge($_GET, $_POST));
if ($result instanceof LogicResult) {
    if ($result->redirect) {
        LibraryFunctions::redirect($result->redirect);
        exit();
    }
    $page_vars = $result->data;
}

// ✅ CORRECT - One line
$page_vars = process_logic(product_logic(array_merge($_GET, $_POST)));

Common Patterns

Feature Toggle Pattern

function feature_logic(array $input): LogicResult {
    $settings = Globalvars::get_instance();

    if (!$settings->get_setting('feature_active')) {
        return LogicResult::error('This feature is not available');
    }

    // Feature logic continues...
    return LogicResult::render($page_vars);
}

Permission Check Pattern

An admin page's header decides who opens it, and every save is checked by its model, so an admin logic function asks a level only for one action that needs more than the page (Permissions):

function admin_thing_logic(array $input): LogicResult {
    $session = SessionControl::get_instance();

    if (LibraryFunctions::isFormSubmission() && ($input['action'] ?? '') === 'rebuild') {
        Access::demand(10, $session);   // a 401 naming the level when refused
        // ...
    }

    $page_vars['can_undelete'] = Access::permits(Thing::access_level('soft_delete'));
    return LogicResult::render($page_vars);
}

A member page's logic asks for a signed-in visitor with $session->check_permission(0).

Form Processing Pattern

function form_logic(array $input): LogicResult {
    if (LibraryFunctions::isFormSubmission()) {
        // Process form
        $user = new User(NULL);
        $user->set('usr_name', $input['name']);
        $user->save();

        // Redirect after POST
        return LogicResult::redirect('/success');
    }

    // Display form
    return LogicResult::render($page_vars);
}

Edit Form Pattern

When editing existing records with FormWriterV2, check edit_primary_key_value from POST first:

function admin_item_edit_logic(array $input): LogicResult {
    // CRITICAL: Check edit_primary_key_value (form submission) first, fallback to GET
    if (isset($input['edit_primary_key_value'])) {
        $item = new Item($input['edit_primary_key_value'], TRUE);
    } elseif (isset($input['itm_item_id'])) {
        $item = new Item($input['itm_item_id'], TRUE);
    } else {
        $item = new Item(NULL);
    }

    if (LibraryFunctions::isFormSubmission()) {
        // Process form...
        $item->save();
        return LogicResult::redirect('/admin/admin_item?itm_item_id=' . $item->key);
    }

    return LogicResult::render(['item' => $item]);
}

See FormWriter Documentation - Edit Forms for complete details on why this pattern is required.

Descriptor functions & descriptor-driven forms

A logic file can expose a *_logic_descriptor() function returning a ['description', 'requires_session', 'mutates', 'ai_agent', 'input' => [...]] array. The input map is one declaration of the form's fields, consumed by three things: the rendered form (via FormWriter's fromDescriptor()), its client-side validation, and the REST/AI surfaces. Edit-form views render the whole body with $fw->fromDescriptor(item_edit_logic_descriptor()) instead of hand-listing fields. The scaffolding generator emits both the logic (with its descriptor) and the matching view automatically — see that guide for the manifest format and the derived/declared/stubbed contract.

The ai_agent key controls whether the joinery_ai agent may call this action, and is default-deny: omit it and the action is not callable by the AI; set 'confirm' to make it callable with a human sign-off on a mutating call, or 'auto' to let it run inline. It pairs with mutates — the confirmation tier only matters for calls that actually mutate. Sensitive flows (credentials, security) deliberately leave it absent. The scaffold's public edit-logic template emits 'ai_agent' => 'confirm'; the admin template leaves it commented out (admin actions opt in deliberately).

Detecting form submission — use isFormSubmission(), never if($input)

Logic functions in the current convention receive a single $input (array_merge($_GET, $_POST)). $input is never empty on a GET — an edit link carries the record id, so if ($input) { … ->save(); } runs the save handler on mere page-open, producing redirect loops (handler redirects to self) or null-overwrite corruption (fields set from undefined $input keys, then saved). Guard every save/mutate/redirect handler on the HTTP method instead:

function admin_item_edit_logic(array $input): LogicResult {
    // load-or-create from $input — runs on both GET and POST
    $item = isset($input['edit_primary_key_value'])
        ? new Item($input['edit_primary_key_value'], TRUE)
        : new Item(NULL);

    if (LibraryFunctions::isFormSubmission()) {   // true only on a real POST
        foreach ($editable_fields as $f) $item->set($f, $input[$f]);
        $item->save();
        return LogicResult::redirect('/admin/admin_item?itm_item_id=' . $item->key);
    }

    return LogicResult::render(['item' => $item]);   // GET → draw the form
}

LibraryFunctions::isFormSubmission() returns true only when $_SERVER['REQUEST_METHOD'] === 'POST'. The meaning of "submitted" lives in one place; never re-derive it from if($input) / if(!empty($input)).

GET-is-read-only invariant (enforced at the database layer)

A page view never persists what a person asked for. A link is a GET, and a browser follows one whenever it is told to, from any site, carrying the session cookie (SameSite=Lax lets a top-level GET through). So everything a person does is a POST button — an altlinks entry describing a post, or AdminPage::action_button() — and a handler acts only when LibraryFunctions::isFormSubmission() says so. Opening a page with ?action=delete on it changes nothing.

The rule is checked below every model and every raw query, in GuardedPdo (includes/GuardedPdo.php): on a web GET, HEAD or OPTIONS, a statement that writes is caught wherever it comes from — a model's save(), a raw INSERT, a plugin, a helper. GuardedPdo::PAGE_VIEW_WRITES says what it meets: 'log' writes one [GET_WRITE] line per place per request to the error log, naming the request, the statement's verb and table (never its values) and the file:line that wrote; 'refuse' throws, naming the same.

The server's own writes during a page view go through SystemBase::server_initiated_write(), the one way past the check:

SystemBase::server_initiated_write(function () use ($row) { $row->save(); });

They are writes no person asked for: an observation (a visitor event, a request log row, an error), a reconciliation (a local row brought into line with a remote fact just fetched), a third-party redirect landing back here (an OAuth or payment return, where persisting the result is the request), a mailed one-time link (its secret, not the session, is the authority, so no other site can make it), or lazy processing a scheduled task would otherwise do. Every caller is listed in tests/unit/core_api_mechanical_test.php, which fails on a new one. A refused save is almost never one of these: the fix is to make the thing doing the saving a POST.

Maintenance never belongs in page logic. Work that changes state as a side effect of displaying a page — external-API sweeps, bulk reconciliation — runs on the cron runner as a scheduled task, never via include() into a logic file.

The CLI (cron, scheduled tasks, scripts) is exempt automatically. See also Admin Pages and Routing.

Error Handling Pattern

When calling code that might throw exceptions (e.g., Stripe, external APIs), catch them and return LogicResult::error():

function checkout_logic(array $input): LogicResult {
    if (LibraryFunctions::isFormSubmission()) {
        try {
            $cart = $session->get_shopping_cart();
            $cart->process_payment($input);
            return LogicResult::redirect('/order-confirmation');

        } catch (Exception $e) {
            return LogicResult::error($e->getMessage(), $input);
        }
    }

    return LogicResult::render($page_vars);
}

Transaction Pattern

Writes that must stand or fall together go in DbConnector::transaction(). It commits when the function returns and rolls back on anything thrown, a PHP Error (a TypeError, an undefined method) as well as an Exception, then rethrows. Called while a transaction is already open, the work runs inside that one and the caller decides. It returns what the function returns. Catch what you mean to show the user outside the call; the rollback has already happened.

try {
    $order = DbConnector::transaction(function ($dblink) use ($cart, $user) {
        $order = Order::from_cart($cart, $user);
        $order->save();
        $cart->clear();
        return $order;
    });
} catch (Exception $e) {
    return LogicResult::error('The order could not be placed.', $input);
}

Do not call beginTransaction() by hand where the helper fits. A function that does (one that must own its transaction, say, and refuse to join a caller's) catches Throwable or closes the transaction in a finally; tests/unit/db_transaction_test.php fails on one that does neither.

A transaction still open when the process ends, or when an uncaught error is about to be recorded, is rolled back by DbConnector::end_abandoned_transaction() and logged as TRANSACTION_LEFT_OPEN at ... with the request or command. That line in the error log means the code that opened it has a way out that neither commits nor rolls back.

Missing/Invalid Parameter Pattern

function event_logic(array $input): LogicResult {
    if (empty($input['event_id'])) {
        return LogicResult::error('Event ID is required');
    }

    $event = new Event($input['event_id'], TRUE);
    if (!$event->get('evt_id')) {
        return LogicResult::error('Event not found');
    }

    // Continue with valid event...
    return LogicResult::render(['event' => $event]);
}

Rules for Logic Files

Never do these in logic files:

// ❌ WRONG - Never call exit()
LibraryFunctions::redirect('/page');
exit();

// ❌ WRONG - Never throw exceptions
throw new SystemDisplayableError('Something went wrong');

// ❌ WRONG - Never set headers directly
header("HTTP/1.0 404 Not Found");
exit();

// ❌ WRONG - Never echo output directly
echo json_encode(['success' => true]);
exit();

// ❌ WRONG - Never return raw arrays in new code
return $page_vars;

Always do these:

// ✅ CORRECT - Return LogicResult for redirects
return LogicResult::redirect('/page');

// ✅ CORRECT - Return LogicResult for errors
return LogicResult::error('Something went wrong');

// ✅ CORRECT - Return LogicResult for page renders
return LogicResult::render($page_vars);

// ✅ CORRECT - Catch exceptions from services and wrap them
try {
    $stripe->charge($amount);
} catch (Exception $e) {
    return LogicResult::error($e->getMessage(), $post_vars);
}

Migration from Legacy Patterns

Converting Old Logic Files

When updating legacy logic files, convert all throw, exit(), and raw array returns:

Redirect conversion:

// Before:
LibraryFunctions::redirect('/some-page');
exit();

// After:
return LogicResult::redirect('/some-page');

Error conversion:

// Before:
throw new SystemDisplayableError('Email is required');

// After:
return LogicResult::error('Email is required');

Array return conversion:

// Before:
return $page_vars;

// After:
return LogicResult::render($page_vars);

Backward Compatibility

process_logic() handles both old and new return formats, so views don't need to change when logic files are migrated:

// This works whether the logic returns LogicResult or a raw array
$page_vars = process_logic(some_logic(array_merge($_GET, $_POST)));

Testing Logic Files

Because logic files return LogicResult objects and never exit() or throw, they can be called directly and asserted against with the shared test harness (see 📖 Testing for the harness API, tiers, and how to run):

/** @joinery-test
 * name: product_logic
 * tier: db
 * env: dev-only
 */
require_once(__DIR__ . '/../lib/harness.php');
harness_boot();

section('render / redirect / error paths');

$result = product_logic(['id' => 1]);
check($result instanceof LogicResult && $result->redirect === null, 'render returns data');
check(!empty($result->data['product']), 'product is present');

$result = product_logic(['delete' => 1]);
check($result->redirect === '/products', 'delete redirects to /products');

$result = product_logic(['id' => 999999]);
check($result->error !== null, 'missing id returns an error result');

harness_finish();

Plugin Logic Files

Plugins can provide their own logic files following the same patterns:

// plugins/bookings/logic/booking_logic.php
function booking_logic(array $input): LogicResult {
    require_once(PathHelper::getIncludePath('plugins/bookings/data/bookings_class.php'));

    // Plugin-specific logic
    $booking = new Booking($input['id'], TRUE);

    return LogicResult::render(['booking' => $booking]);
}

Theme Override Pattern

Themes can override logic files to customize behavior:

/logic/product_logic.php           # Base logic
/theme/canvas/logic/product_logic.php  # Theme override

The theme version will be loaded when using:
require_once(PathHelper::getThemeFilePath('product_logic.php', 'logic'));

Best Practice: Extending Base Logic Without Modifying Core

Instead of completely replacing core logic, themes can create focused logic files that provide additional data to core views. This approach:

  • Keeps core logic untouched
  • Allows multiple themes to coexist with different data needs
  • Makes maintenance easier
  • Follows single responsibility principle
Example: Homepage with Dynamic Content

// /theme/phillyzouk/logic/index_logic.php
// (this theme declares "requires_plugins": ["event_manager"] in theme.json,
//  so the plugin's classes are guaranteed present)
<?php

function index_logic(array $input): LogicResult {
    require_once(PathHelper::getIncludePath('data/posts_class.php'));
    require_once(PathHelper::getIncludePath('plugins/event_manager/data/events_class.php'));

    $page_vars = array();

    // Recent blog posts (4 for the homepage). A collection runs its query the
    // first time the view iterates it, so there is nothing to load here.
    $page_vars['recent_posts'] = new MultiPost(
        array('published' => TRUE, 'deleted' => false),
        array('pst_published_time' => 'DESC'),
        4, 0
    );

    // Upcoming events (6 for the sidebar)
    $page_vars['upcoming_events'] = new MultiEvent(
        array('deleted' => false, 'upcoming' => true),
        array('evt_start_time' => 'ASC'),
        6, 0
    );

    return LogicResult::render($page_vars);
}
?>

Using the Theme Logic in Views

// /theme/phillyzouk/views/index.php
<?php
require_once(PathHelper::getThemeFilePath('PublicPage.php', 'includes'));
require_once(PathHelper::getThemeFilePath('index_logic.php', 'logic'));

$page_vars = process_logic(index_logic(array_merge($_GET, $_POST)));

$page = new PublicPage();
$page->public_header(array(
    'title' => 'Home',
    'showheader' => true
));
?>

<!-- Use $page_vars['recent_posts'] and $page_vars['upcoming_events'] in template -->
<?php foreach ($page_vars['recent_posts'] as $post): ?>
    <!-- Render post -->
<?php endforeach; ?>

Key Advantages of This Pattern

  1. No Core Modification - Base logic files remain unchanged
  2. Theme-Specific Data - Each theme can load different data sets
  3. Clear Separation - Logic layer stays independent from view layer
  4. Easy Debugging - Can inspect $page_vars to see what data is available
  5. Reusable - Other themes can use similar patterns for their needs

Common Issues and Solutions

Issue: "Cannot use object of type LogicResult as array"

Cause: View is calling a logic function directly without process_logic() Solution: Wrap the call with process_logic():

// ❌ Causes error
$page_vars = product_logic(array_merge($_GET, $_POST));
echo $page_vars['product'];

// ✅ Works correctly
$page_vars = process_logic(product_logic(array_merge($_GET, $_POST)));
echo $page_vars['product'];

Issue: Logic file not found

Cause: Incorrect path or naming convention Solution: Ensure file follows [name]_logic.php pattern and use correct include:

require_once(PathHelper::getIncludePath('logic/product_logic.php'));  // Core
require_once(PathHelper::getThemeFilePath('product_logic.php', 'logic')); // Theme-aware

Issue: Redirect not working

Cause: Output sent before redirect, or not using process_logic() Solution: Ensure no echo/print before process_logic() call, and check for PHP errors/warnings

Consistent Variable Naming

Always use these standard variable names:

  • $page_vars - Array of variables to pass to view
  • $settings - Globalvars singleton instance
  • $session - SessionControl singleton instance

Specifications

  • /specs/implemented/logic_result_minimal_spec.md - Phase 1 implementation (redirect/render/error)
  • /specs/logic_result_with_validation_spec.md - Phase 2: complete migration and validation support