🚀

<開発紹介> サブカル系Webメディア【ぽぷかる研究所】をリリースしました!

に公開

バックエンドエンジニアのshunです。

2025年8月にリリースしたサブカル系Webメディア【ぽぷかる研究所】の開発技術の一部を紹介します。

https://popukaru.com/

シリーズ記事

サービス紹介を主題とした「サービス紹介編」の記事と、開発技術を主題とした「開発紹介編」の2本を公開しています。

制作期間

技術選定からインフラの設計構築に始まり、バックエンド開発(Laravel 12, Editor.js)、UI作成、データ移行、関連記事9本執筆等含め、約4ヶ月間の取り組みでした。

  • 着手時期:2025年5月上旬
  • リリース日:2025年8月5日 🎉

技術概要

  • バックエンド(PHP8.2, Laravel 12, Editor.js, MySQL 8, Redis)
  • インフラ( conoha VPS, Apache, Docker)
  • CI/CD (GitHub Actions)
  • 画像配信CDN(AWS S3, CloudFront, Route53)

※サイト内の記事は前身のサイトから移行しています。

全体構成の紹介

今回の「ぽぷかる研究所」の実装にあたり、記事登録や管理などのバックエンドのCMSの機能はLaravel 12で実装し、ホスティングはconoha VPS、画像配信の機構などはAWSを活用して全て新たな構成になりました。
(前身のサイトはconoha WINGWordPressを利用)

リニューアル前後の比較

前身のサイト リニューアル後
ホスティング conoha WING[1] conoha VPS[2]
CMS WordPress Laravel 12, Editor.js
画像配信 conoha WING(同一ホスト) AWS(S3, CloudFront, Route53)

リリース後の構成図


全体構成図

ホスティングはVPS or AWS?(→VPSを選択)

今回のサーバを検討する上で主に2点を判断基準としました。

  1. local,dev,prodなど複数の環境を構築するため、コンテナで実行環境を作成したい
  2. 現時点ではマネタイズしていないので、なるべくランニングコストを抑えたい

選択肢

AWS conoha WING conoha VPS(✅)
コンテナの可否 ⚪︎
(ECS)
×
(Dockerインストール不可)
⚪︎
(Dockerインストール可能)
月額の目安 6,000円程度
(AuroraDBなど含め)
約1,000円
(メモリ 8GBプラン)
約1,500円
(メモリ4GBプラン)

最終的に上記の選択肢で比較検討し、「仮想サーバ丸っといじれてDockerインストールできる」 & 「(AWSと比較すると)断然安い」点を鑑みconoha VPSにしました!

仕事でもAWSを触っていたため、試算する前はなんとなく「AWSでECS, Auroraでやるぞー!」と考えていましたがマネタイズする前のWebメディアの選択肢としてはまだ現実的ではありませんでした。

ただ、VPSにすることでサーバをゼロからセッティングする機会も経験できたのはよかったです。

※環境構築については、以前4シリーズで記事を書いてますので気になる方は以下からご確認お願いします。

https://zenn.dev/shun_nakamura/articles/2c9cdeaefac621

CMS作成の紹介 (Laravel, Editor.js)

一部ですがCMS機能の記事作成画面の赤文字の箇所を紹介します。

以下画像のような画面を作成しました。


記事作成画面(管理画面)

記事コンテンツ(Editor.js)

ブロックスタイルのエディターはEditor.jsを採用しました。
導入もしやすく、使い勝手も良いので採用できて良かったです。

選定理由

  • ブロック毎に編集や位置の入れ替えを行うことができ、使い勝手がWordPress似ている。前身のサイトがWordPressのため、編集担当も慣れている。
  • JSONフォーマットで出力・保存されるため↓
    • Viewを生成する際、PHP側での加工がしやすい
    • PHP側でブロックタイプの判定ができるため、全文検索用データの加工などもしやすい(全文検索については後の章で紹介します)

https://editorjs.io/

保存ボタン(差分検知)

差分検知すると以下のように保存ボタンのUIが変わり保存できる仕様です。
(保存処理は押下以外にも「Command + S」のキーボードでも実行可能)

この差分検知ですが、記事コンテンツ(Editor.js)とサイドバーの記事設定を含めた変更の検知差分の判定非同期のデータ更新を行う必要がありました。

そのため、割としっかりJavascriptで制御する必要があり、もしかしたら今回のCMSの実装で一番時間かかった箇所かもしれないです。(もし次回作る場合はReactやVue.jsの方が良さそうと思いました。)

目次の生成

管理画面のサイドバーの「目次レベル」を設定すると下の画像のように、サーバ側目次生成した上で、管理画面で設定されたHタグのレベルまでの目次がフロントページにレンダリングされます。


目次設定した場合の比較

内部的にはPHP側で際に、

記事コンテンツがJSONフォーマットで保存されているため、詳細ページのViewを生成時にPHP側でデコードして各ブロックタイプからheaderタイプを判定し、目次データも同時に作成するようにしました。

詳細ページ生成クラスの一部コード(目次生成処理を含む)
<?php

namespace App\Services;

use App\Models\Article;

class  ArticleContentService
{
    private $blocks;
    private $contentHtml = '';
    private $indexArray = [];

    public function __construct(Article $article)
    {
        $this->article = $article;
        $content = json_decode($article->content, true);
        $this->blocks = $content['blocks'] ?? [];
    }

    // 記事コンテンツを取得する
    public function getContent(): string
    {
        if (!$this->isAlreadyGeneratedContentHtml()) {
            $headerId = 0;
            foreach ($this->blocks as $block) {

                if ($block['type'] === 'paragraph') {
                    $this->renderParagraph($block);
                }

                if ($block['type'] === 'header') {
                    $headerIdValue = 'header-' . ++$headerId;
                    $this->renderHeader($block, $headerIdValue);
                    // ここで目次データも一緒に作成
                    $this->indexArray[] = [
                        'anchor' => $headerIdValue,
                        'text' => $block['data']['text'],
                        'level' => $block['data']['level'],
                    ];
                }

                if ($block['type'] === 'simpleImage') {
                    $this->renderSimpleImage($block);
                }

                if ($block['type'] === 'quote') {
                    $this->renderQuote($block);
                }

                if ($block['type'] === 'list') {
                    $this->renderList($block);
                }

                if ($block['type'] === 'table') {
                    $this->renderTable($block);
                }

                if ($block['type'] === 'embed') {
                    $this->renderEmbed($block);
                }
            }
        }

        return $this->contentHtml;
    }

    private function isAlreadyGeneratedContentHtml(): bool
    {
        return !empty($this->contentHtml);
    }

    private function renderParagraph($block)
    {
        $data = $block['data'];
        $this->contentHtml .= "<p class=\"font_md margin_b_md\">{$data['text']}</p>";
    }

    private function renderHeader($block, $headerIdValue)
    {
        $data = $block['data'];

        if ($data['level'] == 2) {
            $className = 'headline_h2 margin_t_xl';
        } elseif ($data['level'] == 3) {
            $className = 'headline_h3 margin_t_lg';
        } elseif ($data['level'] == 4) {
            $className = 'headline_h4 margin_t_md';
        } else {
            $className = 'headline_h2 margin_t_lg';
        }

        $this->contentHtml .= "<h{$data['level']} id=\"{$headerIdValue}\" class=\"{$className}\">{$data['text']}</h{$data['level']}>";
    }

    private function renderSimpleImage($block)
    {
        $data = $block['data'];
        $this->contentHtml .= "<figure class=\"margin_b_sm\"><img src=\"{$data['url']}\" alt=\"{$data['caption']}\"><figcaption class=\"text_sub text_center margin_t_sm margin_b_md\">{$data['caption']}</figcaption></figure>";
    }

    private function renderQuote($block)
    {
        $data = $block['data'];
        $this->contentHtml .= "<div class=\"bg_content_sub padding_md margin_b_md\"><blockquote><i>{$data['text']}</i></blockquote><p class=\"text_right text_sub margin_t_md\">{$data['caption']}</p></div>";
    }

    private function renderList($block)
    {
        $data = $block['data'];

        if ($data['style'] === 'unordered') {
            $this->contentHtml .= "<ul class=\"list_dot margin_b_md\">";
        }

        if ($data['style'] === 'ordered') {
            $this->contentHtml .= "<ul class=\"list_number margin_b_md\">";
        }

        if ($data['style'] === 'checklist') {
            $this->contentHtml .= "<ul class=\"list_check margin_b_md\">";
        }

        foreach ($data['items'] as $item) {
            $this->contentHtml .= "<li>{$item['content']}</li>";
        }

        $this->contentHtml .= "</ul>";
    }

    private function renderTable($block)
    {
        $data = $block['data'];
        $this->contentHtml .= "<table class=\"table_block margin_b_md\"><thead><tr>";

        foreach ($data['content'][0] as $header) {
            $this->contentHtml .= "<th>{$header}</th>";
        }

        $this->contentHtml .= "</tr></thead><tbody>";

        foreach (array_slice($data['content'], 1) as $row) {
            $this->contentHtml .= "<tr>";
            foreach ($row as $cell) {
                $this->contentHtml .= "<td>{$cell}</td>";
            }
            $this->contentHtml .= "</tr>";
        }

        $this->contentHtml .= "</tbody></table>";
    }

    private function renderEmbed($block)
    {
        $data = $block['data'];
        $this->contentHtml .= "<div class=\"ratio ratio_golden bg_content_sub\">";
        $this->contentHtml .= "<iframe src=\"{$data['embed']}\" width=\"100%\" height=\"100%\" frameborder=\"0\" allowfullscreen></iframe>";
        $this->contentHtml .= "</div>";
        $this->contentHtml .= "<p class=\"text_center text_sub margin_t_sm margin_b_md\">{$data['caption']}</p>";
    }

    // 目次を取得する
    public function getIndexArray(): array
    {
        if (!$this->isAlreadyGeneratedContentHtml()) {
            $this->getContent();
        }
        return $this->indexArray;
    }
}

MV, タグ, おすすめ記事の設定

管理画面のサイドバーの「MV画像, タグ, おすすめ記事」を選択すると、関連データが以下のようなモーダルで表示され選択できる仕様です。


各種関連データのモーダル

「おすすめ記事」選択の補足

画像右下の「おすすめ記事選択」モーダルでおすすめ記事を設定をすると、記事詳細ページの終わりに設定した記事の導線が表示されます。

編集担当の要望で順番も変更できる仕様が望ましいとのことでしたので、モーダル内ではIDで指定することで並び順も同時に変更できる仕様にしました。


おすすめ記事を設定した管理画面とフロントページ

パフォーマンス関連の紹介

特に目新しいことはないので恐縮ですが一部紹介です。

記事画像はAWSを活用しCDNで配信

ホスティングはVPSですが、画像オブジェクトの管理と配信はAWS(Route53 +S3 + CloudFlont)を活用しました。

主に以下が選定理由です。

  • VPSのストレージを節約(将来的にVPSのプランを上げる確率もかなり下げれる)
  • VPSへのリクエスト数を減らせる
  • AWSリソースなのでCDKでGithubで管理できて管理コストも低い

以下は冒頭で掲載した全体構成図です。赤いラインに変えた箇所が画像配信周りの構成です。

頻繁に発生するクエリはRedisでキャッシュ

主に全ページ共通パーツで使用するクエリの結果をRedisでキャッシュしています。


Redisでクエリ結果をキャッシュしている範囲

LaravelではCacheファザードのrememberForeverメソッドを活用することでスッキリかけて便利でした。

https://readouble.com/laravel/12.x/ja/cache.html

以下のサンプルではRedisのキャッシュに加え、プロセス内ではさらにクラスのプロパティにセットしてデータベースへのリクエストが発生するタイミングを限定しています。

サンプル
use Illuminate\Support\Facades\Cache;

class Article extends Model
{
     private $frontPickupArticles = null;

    // ピックアップ記事
    public function frontPickupArticles()
    {
        if (is_null($this->frontPickupArticles)) {
            $this->frontPickupArticles = Cache::rememberForever(\CacheConst::ARTICLE_PICKUP_KEY, function () {
                return {クエリ}->get();
            });
        }
        return $this->frontPickupArticles;
    }
}

全文検索はMySQLのFULLTEXT INDEXを活用

全文検索(サイト内だとフリーワード検索)は、パフォーマンスを意識し以下の方針で対応しました。

  • 記事テーブルとは別に、全文検索用のテーブルで分ける
  • 管理コストを考え、外部のサービス(algoliaなど)ではなく、MySQLの機能で対応する

今回はMySQLのバージョン的に利用できるFULLTEXT INDEXを採用しました。

管理画面の仕様としては、記事一覧で記事単位で全文検索用のテーブルにデータを更新できるようにしています。


全文検索用テーブルデータを更新する導線

また、フロントページで全文検索(フリーワード検索)のリクエストが来た場合は全文検索用のテーブルに対してMATCH関数で検索しています。

Eloquentのクエリビルダの参考コード
public function frontSearchArticles(ArticleSearchParameters $searchParams)
{

    ...(他の条件の検索は省略)...

    // フリーワード(全文検索・日本語対応)
    if ($searchParams->formatKeywords()->isNotEmpty()) {
        $booleanQuery = $searchParams->formatKeywords()->implode(' '); // OR検索(+ は付けない)

        $query->join('free_words_search_articles', 'articles.id', '=', 'free_words_search_articles.article_id')
            ->whereRaw("MATCH(free_words_search_articles.search_text) AGAINST (? IN BOOLEAN MODE)", [$booleanQuery]);
    }

    return $query->paginate(12);
}

その他

データベースのバックアップ

spatie/laravel-backup」というライブラリを使い、毎日cronジョブでバックアップを取得し、S3へ保存しています。

https://qiita.com/messhii222/items/382304c3597d0b4d0468

最後に

記事を読んでいただき誠にありがとうございます。

簡単ではございますが「ぽぷかる研究所」リリース(開発紹介編)についての内容でした✨

引き続き、“ポップ×カルチャー&ポップ×ローカル”を軸に、アニメ/マンガ/ゲーム/Vtuber/音楽などのエンタメ情報や、最新エンタメニュース、作品の魅力を深堀したコラムやインタビューなどをお届けできればと思いますので今後ともよろしくお願いいたします。(運営一同)

シリーズ記事

脚注
  1. conoha WING ↩︎

  2. conoha VPS ↩︎

Discussion