Components Guide
Learn the DAO, Service, Controller, and Model patterns for building features in Scriptlog.
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
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
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
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
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:
// 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 |