Joomla

WordPressとDrupalの中間に位置するCMS。使いやすさと機能性のバランスが取れている。

CMSオープンソースPHPMVCSymfony
ライセンス
GPL v2
言語
PHP
料金
完全無料(自己ホスト)

CMS

Joomla

概要

Joomlaは、2005年にMamboからフォークして誕生したオープンソースのコンテンツ管理システムです。2024年現在、バージョン5系がリリースされており、PHP 8.1以上、MySQL 8.0.13+、MariaDB 10.4+、PostgreSQL 12+を要求する最新のアーキテクチャを採用しています。企業サイト、ポータルサイト、ECサイト、政府系サイトなど、幅広い用途で使用されています。

詳細

Joomlaは、Model-View-Controller(MVC)アーキテクチャを採用し、コンポーネント、モジュール、プラグインという3つの拡張機能タイプを通じて機能を拡張できます。Symfony フレームワークの一部を利用し、依存性注入(DI)コンテナやイベントディスパッチャーなど、モダンなPHPの機能を活用しています。

主な特徴:

  • 拡張機能エコシステム: 8,000以上の公式拡張機能
  • 多言語サポート: 70以上の言語パックが利用可能
  • アクセシビリティ: Jooa11y アクセシビリティチェッカー搭載
  • セキュリティ: 定期的なセキュリティアップデートと脆弱性対応
  • API サポート: RESTful APIとGraphQLの両方をサポート
  • Joomla Component Builder: 複雑なコンポーネントを効率的に構築
  • ガイドツアー: 管理画面の操作をサポートする機能

メリット・デメリット

メリット

  • 強力な権限管理システム(ACL)
  • 豊富な公式拡張機能
  • 活発なコミュニティサポート
  • 企業向けの高度な機能
  • 優れたセキュリティ機能
  • 多言語サイトの構築が容易
  • MVC アーキテクチャによる開発効率
  • モダンなPHP開発手法の採用

デメリット

  • 学習曲線がやや急
  • WordPressと比較して使用率が低い
  • 拡張機能の互換性問題
  • 管理画面が複雑
  • ホスティング要件が高い
  • アップグレード時の注意が必要

参考ページ

書き方の例

1. Hello World(基本的なセットアップ)

モジュールの基本構造(mod_hello.xml)

<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="site" method="upgrade">
    <name>MOD_HELLO</name>
    <author>Your Name</author>
    <version>1.0.0</version>
    <description>MOD_HELLO_DESCRIPTION</description>
    <namespace path="src">My\Module\Hello</namespace>
    <files>
        <folder>src</folder>
        <folder>tmpl</folder>
        <folder>language</folder>
    </files>
    <config>
        <fields name="params">
            <fieldset name="basic">
                <field
                    name="greeting"
                    type="text"
                    label="MOD_HELLO_GREETING_LABEL"
                    description="MOD_HELLO_GREETING_DESC"
                    default="Hello World!"
                />
            </fieldset>
        </fields>
    </config>
</extension>

サービスプロバイダー(services/provider.php)

<?php
defined('_JEXEC') or die;

use Joomla\CMS\Extension\Service\Provider\Module as ModuleServiceProvider;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory as ModuleDispatcherFactoryServiceProvider;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class () implements ServiceProviderInterface {
    public function register(Container $container): void
    {
        $container->registerServiceProvider(
            new ModuleDispatcherFactoryServiceProvider('\\My\\Module\\Hello')
        );
        $container->registerServiceProvider(new ModuleServiceProvider());
    }
};

2. テーマ開発

テンプレート情報ファイル(templateDetails.xml)

<?xml version="1.0" encoding="utf-8"?>
<extension type="template" client="site" method="upgrade">
    <name>MyTemplate</name>
    <version>1.0.0</version>
    <creationDate>2024-01</creationDate>
    <author>Your Name</author>
    <authorEmail>[email protected]</authorEmail>
    <description>TPL_MYTEMPLATE_DESCRIPTION</description>
    <files>
        <filename>index.php</filename>
        <filename>templateDetails.xml</filename>
        <filename>error.php</filename>
        <filename>offline.php</filename>
        <folder>css</folder>
        <folder>images</folder>
        <folder>js</folder>
        <folder>html</folder>
    </files>
    <positions>
        <position>header</position>
        <position>navigation</position>
        <position>breadcrumb</position>
        <position>content-top</position>
        <position>content-bottom</position>
        <position>sidebar-left</position>
        <position>sidebar-right</position>
        <position>footer</position>
    </positions>
    <config>
        <fields name="params">
            <fieldset name="advanced">
                <field name="siteTitle"
                       type="text"
                       default=""
                       label="TPL_MYTEMPLATE_SITE_TITLE"
                       description="TPL_MYTEMPLATE_SITE_TITLE_DESC"
                />
            </fieldset>
        </fields>
    </config>
</extension>

メインテンプレート(index.php)

<?php
defined('_JEXEC') or die;

use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;

$app = Factory::getApplication();
$doc = $app->getDocument();
$user = $app->getIdentity();
$params = $app->getTemplate(true)->params;

// Web Asset Manager
$wa = $doc->getWebAssetManager();
$wa->useScript('core')
   ->useScript('keepalive')
   ->useScript('form.validate');

// テンプレートスタイルの追加
$wa->registerAndUseStyle('template.main', 'media/templates/site/mytemplate/css/template.css');
?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="<?php echo $this->language; ?>" 
      lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head>
    <jdoc:include type="metas" />
    <jdoc:include type="styles" />
    <jdoc:include type="scripts" />
</head>

<body class="site <?php echo $app->getInput()->getCmd('option', ''); ?>">
    <header id="header" class="header">
        <div class="container">
            <div class="logo">
                <a href="<?php echo Uri::root(); ?>">
                    <?php echo $params->get('siteTitle', $app->get('sitename')); ?>
                </a>
            </div>
            <?php if ($doc->countModules('navigation')) : ?>
                <nav class="navigation">
                    <jdoc:include type="modules" name="navigation" style="none" />
                </nav>
            <?php endif; ?>
        </div>
    </header>

    <?php if ($doc->countModules('breadcrumb')) : ?>
        <div class="breadcrumb-wrap">
            <div class="container">
                <jdoc:include type="modules" name="breadcrumb" style="none" />
            </div>
        </div>
    <?php endif; ?>

    <main id="content" class="main-content">
        <div class="container">
            <div class="row">
                <?php if ($doc->countModules('sidebar-left')) : ?>
                    <aside class="sidebar-left col-md-3">
                        <jdoc:include type="modules" name="sidebar-left" style="well" />
                    </aside>
                <?php endif; ?>

                <div class="content col">
                    <jdoc:include type="message" />
                    <jdoc:include type="component" />
                </div>

                <?php if ($doc->countModules('sidebar-right')) : ?>
                    <aside class="sidebar-right col-md-3">
                        <jdoc:include type="modules" name="sidebar-right" style="well" />
                    </aside>
                <?php endif; ?>
            </div>
        </div>
    </main>

    <footer id="footer" class="footer">
        <div class="container">
            <jdoc:include type="modules" name="footer" style="none" />
            <p>&copy; <?php echo date('Y'); ?> <?php echo $app->get('sitename'); ?></p>
        </div>
    </footer>

    <jdoc:include type="modules" name="debug" style="none" />
</body>
</html>

3. プラグイン開発

プラグイン定義(plg_content_example.xml)

<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="content" method="upgrade">
    <name>PLG_CONTENT_EXAMPLE</name>
    <author>Your Name</author>
    <version>1.0.0</version>
    <description>PLG_CONTENT_EXAMPLE_DESCRIPTION</description>
    <namespace path="src">My\Plugin\Content\Example</namespace>
    <files>
        <folder>src</folder>
        <folder>language</folder>
    </files>
    <config>
        <fields name="params">
            <fieldset name="basic">
                <field
                    name="search_text"
                    type="text"
                    label="PLG_CONTENT_EXAMPLE_SEARCH_TEXT"
                    description="PLG_CONTENT_EXAMPLE_SEARCH_TEXT_DESC"
                />
                <field
                    name="replace_text"
                    type="text"
                    label="PLG_CONTENT_EXAMPLE_REPLACE_TEXT"
                    description="PLG_CONTENT_EXAMPLE_REPLACE_TEXT_DESC"
                />
            </fieldset>
        </fields>
    </config>
</extension>

プラグインクラス(src/Extension/Example.php)

<?php
namespace My\Plugin\Content\Example\Extension;

use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;

class Example extends CMSPlugin implements SubscriberInterface
{
    protected $autoloadLanguage = true;

    public static function getSubscribedEvents(): array
    {
        return [
            'onContentPrepare' => 'onContentPrepare',
            'onContentBeforeDisplay' => 'onContentBeforeDisplay',
            'onContentAfterDisplay' => 'onContentAfterDisplay',
        ];
    }

    public function onContentPrepare(Event $event)
    {
        [$context, $article, $params, $page] = array_values($event->getArguments());

        // プラグインが有効な文脈かチェック
        if ($context !== 'com_content.article') {
            return;
        }

        // パラメータから検索・置換文字列を取得
        $searchText = $this->params->get('search_text', '');
        $replaceText = $this->params->get('replace_text', '');

        if ($searchText && $replaceText) {
            $article->text = str_replace($searchText, $replaceText, $article->text);
        }
    }

    public function onContentBeforeDisplay(Event $event)
    {
        [$context, $article, $params, $limitstart] = array_values($event->getArguments());

        if ($context !== 'com_content.article') {
            return '';
        }

        // 記事の前にコンテンツを追加
        $html = '<div class="content-before">';
        $html .= '<p>記事ID: ' . $article->id . '</p>';
        $html .= '</div>';

        $event->setArgument('result', $html);
    }

    public function onContentAfterDisplay(Event $event)
    {
        [$context, $article, $params, $limitstart] = array_values($event->getArguments());

        if ($context !== 'com_content.article') {
            return '';
        }

        // 記事の後にコンテンツを追加
        $html = '<div class="content-after">';
        $html .= '<p>最終更新: ' . $article->modified . '</p>';
        $html .= '</div>';

        $event->setArgument('result', $html);
    }
}

4. カスタム投稿タイプ(コンポーネント)

コンポーネント定義(administrator/components/com_products/products.xml)

<?xml version="1.0" encoding="utf-8"?>
<extension type="component" method="upgrade">
    <name>COM_PRODUCTS</name>
    <author>Your Name</author>
    <version>1.0.0</version>
    <description>COM_PRODUCTS_DESCRIPTION</description>
    <namespace path="src">My\Component\Products</namespace>
    
    <install>
        <sql>
            <file driver="mysql" charset="utf8">sql/install.mysql.utf8.sql</file>
        </sql>
    </install>
    
    <administration>
        <menu>COM_PRODUCTS</menu>
        <files folder="administrator/components/com_products">
            <folder>sql</folder>
            <folder>src</folder>
            <folder>tmpl</folder>
        </files>
    </administration>
    
    <files folder="components/com_products">
        <folder>src</folder>
        <folder>tmpl</folder>
    </files>
</extension>

データベーステーブル定義(sql/install.mysql.utf8.sql)

CREATE TABLE IF NOT EXISTS `#__products` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `alias` varchar(400) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
  `description` text,
  `price` decimal(10,2) DEFAULT NULL,
  `sku` varchar(64) DEFAULT NULL,
  `state` tinyint(3) NOT NULL DEFAULT 0,
  `catid` int(11) NOT NULL DEFAULT 0,
  `created` datetime NOT NULL,
  `created_by` int(11) NOT NULL DEFAULT 0,
  `modified` datetime NOT NULL,
  `modified_by` int(11) NOT NULL DEFAULT 0,
  `publish_up` datetime,
  `publish_down` datetime,
  `version` int(11) NOT NULL DEFAULT 1,
  `ordering` int(11) NOT NULL DEFAULT 0,
  `metakey` text,
  `metadesc` text,
  `access` int(11) NOT NULL DEFAULT 1,
  `hits` int(11) NOT NULL DEFAULT 0,
  `metadata` text,
  `featured` tinyint(3) unsigned NOT NULL DEFAULT 0,
  `language` char(7) NOT NULL DEFAULT '*',
  PRIMARY KEY (`id`),
  KEY `idx_access` (`access`),
  KEY `idx_catid` (`catid`),
  KEY `idx_state` (`state`),
  KEY `idx_created_by` (`created_by`),
  KEY `idx_language` (`language`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;

5. データベース操作

モデルクラス(src/Model/ProductModel.php)

<?php
namespace My\Component\Products\Site\Model;

use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\Table\Table;
use Joomla\Database\ParameterType;

class ProductModel extends BaseDatabaseModel
{
    /**
     * 製品を取得
     */
    public function getItem($pk = null)
    {
        $pk = (!empty($pk)) ? $pk : (int) $this->getState('product.id');
        
        if ($this->_item === null) {
            $this->_item = [];
        }
        
        if (!isset($this->_item[$pk])) {
            try {
                $db = $this->getDatabase();
                $query = $db->getQuery(true);
                
                $query->select('a.*')
                    ->from($db->quoteName('#__products', 'a'))
                    ->where($db->quoteName('a.id') . ' = :id')
                    ->bind(':id', $pk, ParameterType::INTEGER);
                
                // 公開状態の確認
                $query->where($db->quoteName('a.state') . ' = 1');
                
                $db->setQuery($query);
                $data = $db->loadObject();
                
                if (empty($data)) {
                    throw new \Exception('Product not found', 404);
                }
                
                $this->_item[$pk] = $data;
            } catch (\Exception $e) {
                $this->setError($e->getMessage());
                $this->_item[$pk] = false;
            }
        }
        
        return $this->_item[$pk];
    }
    
    /**
     * 製品リストを取得
     */
    public function getItems()
    {
        $db = $this->getDatabase();
        $query = $db->getQuery(true);
        
        $query->select([
            $db->quoteName('a.id'),
            $db->quoteName('a.title'),
            $db->quoteName('a.alias'),
            $db->quoteName('a.price'),
            $db->quoteName('a.sku'),
            $db->quoteName('a.created'),
            $db->quoteName('a.hits')
        ])
        ->from($db->quoteName('#__products', 'a'))
        ->where($db->quoteName('a.state') . ' = 1');
        
        // カテゴリフィルター
        $categoryId = $this->getState('filter.category_id');
        if ($categoryId) {
            $query->where($db->quoteName('a.catid') . ' = :catid')
                ->bind(':catid', $categoryId, ParameterType::INTEGER);
        }
        
        // 検索フィルター
        $search = $this->getState('filter.search');
        if (!empty($search)) {
            $search = '%' . $db->escape($search, true) . '%';
            $query->where('(' . $db->quoteName('a.title') . ' LIKE :search1 OR ' . 
                         $db->quoteName('a.description') . ' LIKE :search2)')
                ->bind(':search1', $search)
                ->bind(':search2', $search);
        }
        
        // 並び順
        $orderCol = $this->getState('list.ordering', 'a.ordering');
        $orderDirn = $this->getState('list.direction', 'ASC');
        $query->order($db->escape($orderCol . ' ' . $orderDirn));
        
        // ページネーション
        $limit = $this->getState('list.limit', 10);
        $start = $this->getState('list.start', 0);
        
        $db->setQuery($query, $start, $limit);
        
        try {
            $items = $db->loadObjectList();
        } catch (\RuntimeException $e) {
            $this->setError($e->getMessage());
            return false;
        }
        
        return $items;
    }
    
    /**
     * 製品を保存
     */
    public function save($data)
    {
        $table = $this->getTable();
        $key = $table->getKeyName();
        $pk = (!empty($data[$key])) ? $data[$key] : (int) $this->getState($this->getName() . '.id');
        $isNew = true;
        
        try {
            // 既存データの確認
            if ($pk > 0) {
                $table->load($pk);
                $isNew = false;
            }
            
            // データのバインド
            if (!$table->bind($data)) {
                $this->setError($table->getError());
                return false;
            }
            
            // データの検証
            if (!$table->check()) {
                $this->setError($table->getError());
                return false;
            }
            
            // データの保存
            if (!$table->store()) {
                $this->setError($table->getError());
                return false;
            }
            
            // 状態の更新
            $this->setState($this->getName() . '.id', $table->$key);
            
        } catch (\Exception $e) {
            $this->setError($e->getMessage());
            return false;
        }
        
        return true;
    }
    
    /**
     * テーブルオブジェクトを取得
     */
    public function getTable($name = 'Product', $prefix = 'ProductsTable', $options = [])
    {
        return parent::getTable($name, $prefix, $options);
    }
}

6. API連携

APIコントローラー(src/Controller/ProductsController.php)

<?php
namespace My\Component\Products\Api\Controller;

use Joomla\CMS\MVC\Controller\ApiController;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Component\Fields\Administrator\Helper\FieldsHelper;

class ProductsController extends ApiController
{
    protected $contentType = 'products';
    
    protected $default_view = 'products';
    
    /**
     * 製品リストを取得(GET)
     */
    public function displayList()
    {
        $model = $this->getModel('Products', 'Site');
        
        // フィルターパラメータの設定
        $app = $this->app;
        $model->setState('filter.category_id', $app->input->get('category_id', 0, 'int'));
        $model->setState('filter.search', $app->input->get('search', '', 'string'));
        $model->setState('list.limit', $app->input->get('limit', 20, 'int'));
        $model->setState('list.start', $app->input->get('offset', 0, 'int'));
        
        $items = $model->getItems();
        
        if ($items === false) {
            throw new \Exception($model->getError(), 500);
        }
        
        // レスポンスの準備
        $data = [];
        foreach ($items as $item) {
            $data[] = $this->prepareItem($item);
        }
        
        return $data;
    }
    
    /**
     * 単一の製品を取得(GET)
     */
    public function displayItem()
    {
        $id = $this->app->input->get('id', 0, 'int');
        
        if (!$id) {
            throw new \Exception('Invalid product ID', 400);
        }
        
        $model = $this->getModel('Product', 'Site');
        $item = $model->getItem($id);
        
        if (!$item) {
            throw new \Exception('Product not found', 404);
        }
        
        return $this->prepareItem($item);
    }
    
    /**
     * 製品を作成(POST)
     */
    public function add()
    {
        $this->checkToken();
        
        $data = $this->input->get('data', [], 'array');
        
        // 必須フィールドの検証
        if (empty($data['title'])) {
            throw new \Exception('Title is required', 400);
        }
        
        $model = $this->getModel('Product', 'Administrator');
        
        if (!$model->save($data)) {
            throw new \Exception($model->getError(), 500);
        }
        
        $id = $model->getState('product.id');
        
        return [
            'id' => $id,
            'message' => 'Product created successfully'
        ];
    }
    
    /**
     * 製品を更新(PATCH)
     */
    public function edit()
    {
        $this->checkToken();
        
        $id = $this->app->input->get('id', 0, 'int');
        
        if (!$id) {
            throw new \Exception('Invalid product ID', 400);
        }
        
        $data = $this->input->get('data', [], 'array');
        $data['id'] = $id;
        
        $model = $this->getModel('Product', 'Administrator');
        
        if (!$model->save($data)) {
            throw new \Exception($model->getError(), 500);
        }
        
        return [
            'id' => $id,
            'message' => 'Product updated successfully'
        ];
    }
    
    /**
     * 製品を削除(DELETE)
     */
    public function delete()
    {
        $this->checkToken();
        
        $id = $this->app->input->get('id', 0, 'int');
        
        if (!$id) {
            throw new \Exception('Invalid product ID', 400);
        }
        
        $model = $this->getModel('Product', 'Administrator');
        
        if (!$model->delete($id)) {
            throw new \Exception($model->getError(), 500);
        }
        
        return [
            'message' => 'Product deleted successfully'
        ];
    }
    
    /**
     * アイテムをAPI用に準備
     */
    protected function prepareItem($item)
    {
        return [
            'id' => (int) $item->id,
            'title' => $item->title,
            'alias' => $item->alias,
            'description' => $item->description,
            'price' => (float) $item->price,
            'sku' => $item->sku,
            'created' => $item->created,
            'modified' => $item->modified,
            'hits' => (int) $item->hits
        ];
    }
}