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 overridesLogic 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 redirectLogicResult::error()— adds error message to session, returns data for re-displayLogicResult::render()— returns the data array- Legacy array returns — passes through unchanged for backward compatibility
// ❌ 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
// /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
- No Core Modification - Base logic files remain unchanged
- Theme-Specific Data - Each theme can load different data sets
- Clear Separation - Logic layer stays independent from view layer
- Easy Debugging - Can inspect
$page_varsto see what data is available - 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-awareIssue: 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
Related Documentation
- Plugin Developer Guide - For plugin-specific logic patterns
- Admin Pages Documentation - For admin interface logic
- Scaffolding / Code Generator - Generate logic + view + data class from one manifest
- Main Architecture Guide - For overall system architecture
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