PSR-4 Autoloading¶
Overview¶
PSR-4 describes a specification for autoloading classes from file paths. XOOPS 2026 fully adopts PSR-4, replacing the legacy class loading mechanisms with a standardized, Composer-compatible approach.
The PSR-4 Standard¶
Key Principles¶
- Fully Qualified Class Name (FQCN) must have a top-level namespace
- Namespace prefixes map to base directories
- Subdirectory names correspond to sub-namespace names
- File names must match class names with
.phpextension
Mapping Formula¶
Namespace Prefix → Base Directory
Xoops\Module\News\ → modules/news/src/
Class: Xoops\Module\News\Controller\ArticleController
File: modules/news/src/Controller/ArticleController.php
XOOPS Module Structure¶
Directory Layout¶
modules/publisher/
├── composer.json # Module-specific dependencies
├── module.json # Module manifest
├── xoops_version.php # Legacy metadata (for compatibility)
├── src/ # PSR-4 autoloaded source
│ ├── Controller/
│ │ ├── ArticleController.php
│ │ └── CategoryController.php
│ ├── Entity/
│ │ ├── Article.php
│ │ └── Category.php
│ ├── Repository/
│ │ ├── ArticleRepository.php
│ │ └── CategoryRepository.php
│ ├── Service/
│ │ ├── ArticleService.php
│ │ └── SearchService.php
│ └── Helper.php
├── class/ # Legacy classes (deprecated)
│ └── Handler/ # Legacy handlers for BC
├── templates/
├── language/
└── assets/
Namespace Convention¶
XOOPS 2026 uses a standardized namespace pattern:
Examples:
Xoops\Module\Publisher\Controller\ArticleControllerXoops\Module\Publisher\Entity\ArticleXoops\Module\Publisher\Service\ArticleServiceXoops\Module\Publisher\Repository\ArticleRepository
Composer Configuration¶
Module composer.json¶
{
"name": "xoopsmodules/publisher",
"description": "XOOPS Publisher Module",
"type": "xoops-module",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.2",
"xoops/xoops-core": "^2026.0"
},
"autoload": {
"psr-4": {
"Xoops\\Module\\Publisher\\": "src/"
}
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.0"
}
}
Core XOOPS composer.json¶
{
"name": "xoops/xoops-core",
"description": "XOOPS Content Management System",
"type": "project",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.2",
"psr/http-message": "^2.0",
"psr/container": "^2.0",
"psr/log": "^3.0"
},
"autoload": {
"psr-4": {
"Xoops\\Core\\": "class/Core/",
"Xoops\\Kernel\\": "class/Kernel/",
"Xmf\\": "class/Xmf/"
}
}
}
Class Examples¶
Controller Class¶
<?php
declare(strict_types=1);
namespace Xoops\Module\Publisher\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Xoops\Core\View\ViewRendererInterface;
use Xoops\Module\Publisher\Service\ArticleService;
/**
* Article Controller
*
* @package Xoops\Module\Publisher\Controller
*/
class ArticleController
{
public function __construct(
private readonly ArticleService $articleService,
private readonly ViewRendererInterface $view
) {}
public function list(ServerRequestInterface $request): ResponseInterface
{
$page = (int) ($request->getQueryParams()['page'] ?? 1);
$articles = $this->articleService->getPaginated($page);
return $this->view->render('@modules/publisher/article/list', [
'articles' => $articles,
'currentPage' => $page,
]);
}
public function view(ServerRequestInterface $request, int $id): ResponseInterface
{
$article = $this->articleService->findById($id);
if ($article === null) {
throw new NotFoundException('Article not found');
}
return $this->view->render('@modules/publisher/article/view', [
'article' => $article,
]);
}
}
Entity Class¶
<?php
declare(strict_types=1);
namespace Xoops\Module\Publisher\Entity;
/**
* Article Entity
*
* @package Xoops\Module\Publisher\Entity
*/
class Article
{
public function __construct(
public readonly int $id,
public string $title,
public string $content,
public int $authorId,
public int $categoryId,
public bool $published = false,
public ?\DateTimeImmutable $publishedAt = null,
public readonly \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
public ?\DateTimeImmutable $updatedAt = null,
) {}
public function publish(): void
{
$this->published = true;
$this->publishedAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
public function unpublish(): void
{
$this->published = false;
$this->publishedAt = null;
$this->updatedAt = new \DateTimeImmutable();
}
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content,
'author_id' => $this->authorId,
'category_id' => $this->categoryId,
'published' => $this->published,
'published_at' => $this->publishedAt?->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt?->format('Y-m-d H:i:s'),
];
}
}
Repository Class¶
<?php
declare(strict_types=1);
namespace Xoops\Module\Publisher\Repository;
use Xoops\Core\Database\ConnectionInterface;
use Xoops\Module\Publisher\Entity\Article;
/**
* Article Repository
*
* @package Xoops\Module\Publisher\Repository
*/
class ArticleRepository implements ArticleRepositoryInterface
{
public function __construct(
private readonly ConnectionInterface $connection
) {}
public function findById(int $id): ?Article
{
$qb = $this->connection->createQueryBuilder();
$qb->select('*')
->from('publisher_articles')
->where('id = :id')
->setParameter('id', $id);
$row = $qb->fetchAssociative();
return $row ? $this->hydrate($row) : null;
}
public function findPublished(int $limit = 10, int $offset = 0): array
{
$qb = $this->connection->createQueryBuilder();
$qb->select('*')
->from('publisher_articles')
->where('published = :published')
->setParameter('published', true)
->orderBy('published_at', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
$rows = $qb->fetchAllAssociative();
return array_map([$this, 'hydrate'], $rows);
}
public function save(Article $article): Article
{
$data = $article->toArray();
unset($data['id'], $data['created_at']);
if ($article->id === 0) {
$this->connection->insert('publisher_articles', $data);
$id = (int) $this->connection->lastInsertId();
return new Article($id, ...array_values($data));
}
$this->connection->update('publisher_articles', $data, ['id' => $article->id]);
return $article;
}
private function hydrate(array $row): Article
{
return new Article(
id: (int) $row['id'],
title: $row['title'],
content: $row['content'],
authorId: (int) $row['author_id'],
categoryId: (int) $row['category_id'],
published: (bool) $row['published'],
publishedAt: $row['published_at']
? new \DateTimeImmutable($row['published_at'])
: null,
createdAt: new \DateTimeImmutable($row['created_at']),
updatedAt: $row['updated_at']
? new \DateTimeImmutable($row['updated_at'])
: null,
);
}
}
Service Class¶
<?php
declare(strict_types=1);
namespace Xoops\Module\Publisher\Service;
use Xoops\Module\Publisher\Entity\Article;
use Xoops\Module\Publisher\Repository\ArticleRepositoryInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* Article Service
*
* @package Xoops\Module\Publisher\Service
*/
class ArticleService
{
private const ARTICLES_PER_PAGE = 20;
public function __construct(
private readonly ArticleRepositoryInterface $repository,
private readonly EventDispatcherInterface $eventDispatcher
) {}
public function findById(int $id): ?Article
{
return $this->repository->findById($id);
}
public function getPaginated(int $page = 1): array
{
$offset = ($page - 1) * self::ARTICLES_PER_PAGE;
return $this->repository->findPublished(
self::ARTICLES_PER_PAGE,
$offset
);
}
public function publish(int $articleId): Article
{
$article = $this->repository->findById($articleId);
if ($article === null) {
throw new ArticleNotFoundException("Article {$articleId} not found");
}
$article->publish();
$article = $this->repository->save($article);
$this->eventDispatcher->dispatch(
new ArticlePublishedEvent($article->id, $article->authorId)
);
return $article;
}
}
Migration from Legacy Classes¶
Legacy Class Location¶
modules/publisher/class/
├── Article.php # class PublisherArticle extends XoopsObject
├── ArticleHandler.php # class PublisherArticleHandler extends XoopsPersistableObjectHandler
└── Category.php
Migration Strategy¶
Step 1: Create Modern Equivalents¶
Keep legacy classes but create new PSR-4 classes:
// modules/publisher/src/Entity/Article.php (new)
namespace Xoops\Module\Publisher\Entity;
class Article { /* ... */ }
Step 2: Create Adapter for Legacy Handler¶
// modules/publisher/src/Repository/LegacyArticleRepository.php
namespace Xoops\Module\Publisher\Repository;
use Xoops\Module\Publisher\Entity\Article;
class LegacyArticleRepository implements ArticleRepositoryInterface
{
private \PublisherArticleHandler $handler;
public function __construct()
{
$helper = \Xoops\Module\Publisher\Helper::getInstance();
$this->handler = $helper->getHandler('Article');
}
public function findById(int $id): ?Article
{
$obj = $this->handler->get($id);
if ($obj === false) {
return null;
}
return $this->convertToEntity($obj);
}
private function convertToEntity(\PublisherArticle $obj): Article
{
return new Article(
id: (int) $obj->getVar('articleid'),
title: $obj->getVar('title'),
content: $obj->getVar('body'),
// ... map other fields
);
}
}
Step 3: Use Interface for Flexibility¶
// Register based on configuration
$container->set(ArticleRepositoryInterface::class, function($c) {
$useLegacy = $c->get('config')->get('publisher.use_legacy_handler');
return $useLegacy
? new LegacyArticleRepository()
: new ArticleRepository($c->get('database'));
});
Autoloader Registration¶
Core Bootstrap¶
// include/common.php
<?php
// Load Composer autoloader
require_once XOOPS_ROOT_PATH . '/vendor/autoload.php';
// The autoloader handles all PSR-4 namespaced classes automatically
// No manual require_once needed for classes following PSR-4
Module Autoloading¶
XOOPS core registers module namespaces automatically:
// Automatic registration for all modules
// Xoops\Module\{ModuleName}\ → modules/{modulename}/src/
$loader = require XOOPS_ROOT_PATH . '/vendor/autoload.php';
// Register active modules
foreach ($activeModules as $module) {
$namespace = 'Xoops\\Module\\' . ucfirst($module->dirname) . '\\';
$path = XOOPS_ROOT_PATH . '/modules/' . $module->dirname . '/src/';
if (is_dir($path)) {
$loader->addPsr4($namespace, $path);
}
}
Best Practices¶
1. One Class Per File¶
// Correct: ArticleController.php contains only ArticleController
// Wrong: Multiple classes in one file
2. Match Namespace to Path¶
// Class: Xoops\Module\Publisher\Controller\Admin\ArticleController
// File: modules/publisher/src/Controller/Admin/ArticleController.php
3. Use Strict Types¶
4. Follow Naming Conventions¶
| Type | Convention | Example |
|---|---|---|
| Class | PascalCase | ArticleController |
| Method | camelCase | findById() |
| Property | camelCase | $articleService |
| Constant | UPPER_SNAKE | MAX_ARTICLES |
IDE Configuration¶
PHPStorm¶
PHPStorm automatically detects composer.json and configures autoloading.
VS Code¶
Install PHP Intelephense extension and ensure composer.json is present.