【Laravel】シンプルにCMSを実装する(CKEditor 5)

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、「PHPでサイトをつくる」といえば、近年ではLaravel、そしてwordpressがメインの選択肢になるのではないでしょうか。

(2020.12.26現在では)なんとウェブサイトの39%wordpressで構築されているということでPHP業界だけでなく大きな影響を持っています。

そして、そんな人気からたまに直面する課題が・・・・・・

Laravelでwordpressみたいな「CMS」が使いたい

というものです。(CMS = コンテンツ・マネジメント・システム)

以前このブログで、ログイン中のLaravelユーザーでwordpressへ自動ログインするという記事を公開しましたが、「正直言うとwordpressほど高機能でなくてもよくて、シンプルにいきたい!」という場面もあるかもしれません。

そこで❗

今回は、Laravelに「シンプルなCMS」をつくる方法をご紹介します。
利用するのは、CKEditor 5です。

CKEditorは、簡単にいうと「画像やHTMLタグが使える入力ボックス」で、いわゆる「リッチ・テキストエディタ」とか「wysiwyg(うぃじうぃぐ)エディタ」と呼ばれるものです。

ぜひ皆さんのお役にたてると嬉しいです😊✨
(最後に、今回実際に開発したソースコード一式をダウンロードできますよ👍)

【追記:2021.01.14】コードが古いまま残っている部分がありましたので修正しました。ご不便をおかけしてすみません。m(_ _)m

「着るコタツがリコール回収されることに。
気に入ってたのに・・・😫」

開発環境: Laravel 8.x、Vue 3、axios 0.19、CKEditor 5

ルートをつくる

まずはルートをつくります。
以下の内容を追加してください。

routes/web.php

Route::prefix('post')->group(function(){

    Route::get('/', [PostController::class, 'index']);
    Route::get('/list', [PostController::class, 'list']);
    Route::get('/{post}', [PostController::class, 'show']);
    Route::post('/', [PostController::class, 'store']);
    Route::put('/{post}', [PostController::class, 'update']);
    Route::delete('/{post}', [PostController::class, 'destroy']);

});

⚠ なお、今回はテストのため省略していますが、2つ目のルートshow()以外は管理者ログインせずに表示されるべきではありません。以下を参考にしてauthミドルウェアなどをつけて対応してください。

📝 【Laravel 5.8 】「こんなとき」のミドルウェア全7実例

コントローラーをつくる

続いてコントローラーです。
まず以下のコマンドを実行してください。

php artisan make:controller PostController

すると、ファイルが作成されるので、中身を以下のように変更してください。

app/Http/Controllers/PostController.php

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class PostController extends Controller
{
    public function index() {

        return view('post.index');

    }

    public function list() {

        return Post::get();

    }

    public function show(Post $post) {

        return view('post.show')->with([
            'post' => $post
        ]);

    }

    public function store(Request $request) {

        // バリデーションは省略してます

        $post = new Post();
        $post->title = $request->title;
        $post->description = $request->description;
        $result = $post->save();

        return ['result' => $result];

    }

    public function update(Request $request, Post $post) {

        // バリデーションは省略してます

        $post->title = $request->title;
        $post->description = $request->description;
        $result = $post->save();

        return ['result' => $result];

    }

    public function destroy(Post $post) {

        return [
            'result' => $post->delete()
        ];

    }
}

今回、この中のindex()show()が直接ブラウザでアクセスされるメソッドで、それ以外は全てAjaxでのアクセスを想定しています。

モデル&マイグレーションをつくる

次に、モデルとマイグレーションをつくります。
以下のコマンドを実行してください。

php artisan make:model Post -m

すると、モデルとマイグレーションのファイルが作成されるので、マイグレーションの中身を以下のように変更します。

database/migrations/****_**_**_******_create_posts_table.php

// 省略

public function up()
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title')->comment('タイトル');
        $table->text('description')->comment('内容');
        $table->timestamps();
    });
}

// 省略

なお、記事の「表示 or 非表示」機能をつけたい場合は、wordpressのようにstatusのような項目を追加しておくといいでしょう。

テストデータをつくる

では、作業がしやすいようにテストデータをつくります。
以下のコマンドを実行してください。

php artisan make:seed PostsTableSeeder

すると、ファイルが作成されるので中身を以下のように変更してください。

<?php

namespace Database\Seeders;

use App\Models\Post;
use Illuminate\Database\Seeder;

class PostsTableSeeder extends Seeder
{
    // 省略

    public function run()
    {
        for($i = 0; $i < 25 ; $i++) {

            $post = new Post();
            $post->title = 'テストタイトル - '. $i;
            $post->description = "テスト内容\nテスト内容\nテスト内容\nテスト内容 - ". $i;
            $post->save();

        }
    }
}

続いて、このテストデータが有効になるように、Laravel側へ登録します。

database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
//         User::factory(10)->create();
        $this->call(PostsTableSeeder::class); // 👈 ここを追加しました
    }
}

では、この状態でマイグレーションを実行します。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

実行が完了するとデータベースは以下のようになります。

ビューをつくる

では、ブラウザで実際に表示される「ビュー」を作っていきましょう。
今回は以下の2つを用意します。

  • 記事を管理するページ
  • 投稿した個別記事を表示するページ

まずは、記事を管理するページです。

resources/views/post/index.blade.php

<html>
<head>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css">
</head>
<body>
<div id="app" class="container p-3">
    <h1 class="mb-4">シンプルなCMSのサンプル</h1>

    <!-- 一覧表示部分 -->
    <div v-if="isStatusIndex">
        <div class="text-right pb-4">
            <button type="button" class="btn btn-success" @click="changeStatus('create')">追加</button>
        </div>
        <table class="table">
            <tr v-for="post in posts">
                <td v-text="post.title"></td>
                <td class="text-right">
                    <a :href="'/post/'+ post.id" class="btn btn-light mr-2" target="_blank">確認</a>
                    <button type="button" class="btn btn-warning mr-2" @click="setCurrentPost(post)">変更</button>
                    <button type="button" class="btn btn-danger" @click="onDelete(post)">削除</button>
                </td>
            </tr>
        </table>
    </div>

    <!--  エディタ表示部分  -->
    <div v-if="isStatusCreate || isStatusEdit">

        <input class="form-control mb-3" type="text" placeholder="タイトル" v-model="postTitle">

        <!-- ここにリッチテキスト・エディタが表示されます -->
        <div id="editor"></div>

        <div class="text-right pt-4">
            <button type="button" class="btn btn-secondary mr-2" @click="changeStatus('index')">キャンセル</button>
            <button type="button" class="btn btn-primary" @click="onSave">保存する</button>
        </div>
    </div>

</div>
<script src="https://unpkg.com/vue@3.0.2/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script>
<script src="https://cdn.ckeditor.com/ckeditor5/24.0.0/classic/ckeditor.js"></script>
<script>

    Vue.createApp({
        data() {
            return {
                status: 'index', // 👈 ここの内容で表示切り替え
                posts: [],
                currentPost: {},
                postTitle: '',  // タイトル
                richEditor: null    // CKEditorのインスタンス
            }
        },
        methods: {
            initRichEditor(defaultDescription) {

                const target = document.querySelector('#editor');
                ClassicEditor.create(target)
                    .then(editor => {

                        this.postTitle = this.currentPost.title || '';
                        this.richEditor = editor;
                        this.richEditor.setData(defaultDescription);

                    });

            },
            getPosts() {

                const url = '/post/list';
                axios.get(url)
                    .then(response => {

                        this.posts = response.data;

                    });

            },
            setCurrentPost(post) {

                this.currentPost = post;
                this.status = 'edit';

            },
            changeStatus(status) {

                this.status = status;

            },
            onSave() {

                if(confirm('保存します。よろしいですか?')) {

                    let url = '';
                    let method = '';

                    if(this.isStatusCreate) {

                        url = '/post';
                        method = 'POST';

                    } else if(this.isStatusEdit) {

                        url = `/post/${this.currentPost.id}`;
                        method = 'PUT';

                    }

                    const params = {
                        _method: method,
                        title: this.postTitle,
                        description: this.richEditor.getData()
                    };
                    axios.post(url, params)
                        .then(response => {

                            if(response.data.result === true) {

                                this.getPosts();
                                this.changeStatus('index');

                            }

                        })
                        .catch(error => {

                            console.log(error); // エラーの場合

                        });

                }

            },
            onDelete(post) {

                if(confirm('削除します。よろしいですか?')) {

                    const url = `/post/${post.id}`;
                    axios.delete(url)
                        .then(response => {

                            if(response.data.result === true) {

                                this.getPosts();

                            }

                        });

                }

            }
        },
        computed: {
            isStatusIndex() {

                return (this.status === 'index');

            },
            isStatusCreate() {

                return (this.status === 'create');

            },
            isStatusEdit() {

                return (this.status === 'edit');

            }
        },
        watch: {
            status(value) {

                if(value === 'create') {

                    this.currentPost = {};

                }

                const editorKeys = ['create', 'edit'];
                const defaultDescription = (value === 'edit') ? this.currentPost.description : '';

                if(editorKeys.includes(value)) { // 👈 `create` か `edit` の場合だけ CKEditor を起動

                    Vue.nextTick(() => {

                        this.initRichEditor(defaultDescription);

                    });

                }

            }
        },
        setup() {

            return {
                richEditor: Vue.reactive({}) // 👈 reactive変数をつくる
            }

        },
        mounted() {

            this.getPosts();

        }
    }).mount('#app');

</script>
</body>
</html>

この中で重要なのが、CKEditorを起動している部分です。

CKEditorを起動したら以下のようにVueの変数に格納していますが、これは通常の変数ではありません。

this.richEditor = editor;

これは、setup()内でセットしたリアクティブな変数です。

setup() {

    return {
        richEditor: Vue.reactive({}) // 👈 これです
    }

}

また、変数statusを変更することでVueが表示切り替えをしていることにも注目してください。

切り替え内容は次のとおりです。

  • index: 一覧表示
  • create: タイトル、CKEditor を表示(中身は空)
  • edit: タイトル、CKEditor を表示(中身は該当データ)

次に、個別詳細ページのビューです。

resources/views/post/show.blade.php

<html>
<head>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css">
</head>
<body>
<div id="app" class="container p-3">
    <h1 class="mb-4">{{ $post->title }}</h1>
    {!! $post->description !!}
</div>
</body>
</html>

こちらは、$post->descriptionの部分がエスケープ処理をしない、{!! ... !!}になっていることに注意してください。

なぜなら、CKEditorで登録された内容はHTMLタグが含まれているからです。

おまけ:画像のアップロード機能をつける

CKEditorで画像アップロード機能を使えるようにするためには、有料のプラグインを使うか、独自にアダプターをつくる必要があります。

もちろん有料プラグインは多機能でいいのですが、今回は「シンプルなCMS」がテーマなので独自アダプターで実装してみましょう。

専用のアダプター・クラスをつくる

まず、JavaScriptのクラスをつくります。(これがアダプターになります)

resources/views/post/index.blade.php

<!-- 省略 -->

<script>

    class UploadAdapter {

        constructor(loader) {

            this.loader = loader;

        }

        upload() {

            return this.loader.file
                .then(file => {

                    return new Promise((resolve, reject) => {

                        const url = '/post/upload_image';
                        let formData = new FormData();
                        formData.append('image', file);

                        axios.post(url, formData)
                            .then(response => {

                                if(response.data.result === true) {

                                    const imageUrl = response.data.image_url;
                                    resolve({ default: imageUrl });

                                } else {

                                    reject();

                                }

                            })
                            .catch(error => {

                                reject();

                            });

                    });

                });

        }

    }

// 省略

中身としては、以下のことを実行しています。

  1. 画像が選択されたら、axios でファイルを Laravel へ送信
  2. Laravel で画像を保存
  3. 返された画像URLが <img> タグに使われるように resolve() にセット

そして、このアダプターをVue内につくったinitRichEditor()でセットします。

// 省略

initRichEditor(defaultDescription) {

    const target = document.querySelector('#editor');
    ClassicEditor.create(target)
        .then(editor => {

            this.postTitle = this.currentPost.title || '';
            this.richEditor = editor;
            // 👇 ここでアダプターをセットします
            this.richEditor.plugins
                .get('FileRepository')
                .createUploadAdapter = loader  => {

                    return new UploadAdapter(loader);

                };
            this.richEditor.setData(defaultDescription);

        });

}

ファイルアップロード部分をつくる

画像のアップロード機能をつくります。
まず、アップロード用のルートを追加してください。

routes/web.php

Route::prefix('post')->group(function(){

    // 省略
    Route::post('/', [PostController::class, 'store']);
    Route::post('/upload_image', [PostController::class, 'upload_image']); // 👈 ここを追加しました
    // 省略

});

そして、コントローラーにupload_image()メソッドを追加します。

app/Http/Controllers/PostController.php

<?php

// 省略

class PostController extends Controller
{
    // 省略

    public function upload_image(Request $request) {

        // バリデーションは省略してます

        $file = $request->file('image');
        $extension = $file->extension();
        $path = 'public/cms_images';
        $name = date('Ymd-His') .'_'. Str::random(5) .'.'. $extension;
        $request->file('image')->storeAs($path, $name);

        return [
            'result' => true,
            'image_url' => url('/storage/cms_images/'. $name)
        ];

    }
}

最後に忘れてはいけないのが、以下のコマンドです。

php artisan storage:link

このコマンドを実行することで、/publicフォルダ内に/storage/app/publicへのシンボリックリンクが貼られることになり、ブラウザから直接アクセスができるようになります。

※なお、もし画像の管理をしたいということであればcms_imagesのようなDBテーブルを用意し、そこにパスなどを保存しておくといいでしょう。

テストしてみる

では、実際にテストしてみましょう❗

まずは、「http://******/post」にブラウザでアクセスします。

テストデータが一覧表示されるので、まずは、新規追加です。
追加」ボタンをクリックしてみましょう。

すると・・・・・・

タイトルとCKEditorが表示されました。
では、以下のように入力して保存してみましょう❗

すると・・・・・・

画面が切り替わり、データが追加されました。

では、「確認」ボタンをクリックしてみましょう。

はい❗
先ほど投稿した内容が表示されています。

成功です😊✨

では、次に「変更」ボタンをクリックします。
すると、初期状態でタイトル、CKEditorにテキストが表示されています。

これを、変更して保存して確認してみると・・・・・・

はい❗
こちらもうまく変更できました。

成功です😊✨

ダウンロードする

以下から、今回実際に開発したソースコード一式をダウンロードできます。

【Laravel】シンプルにCMSを実装する

※ ただしマイグレーションなどはご自身で実行してください。

CKEditor 5 を使うときの注意点

CKEditor 5のライセンスは「GPL 2+」です。
MITライセンスと違い、CKEditorを含む「派生アプリケーション」を配布する場合、ソースコードの開示が義務付けられています。

そのため、CKEditorを含むアプリケーションを配布する場合は注意が必要です。

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はLaravel + CKEditor 5でシンプルなCMSを作ってみました。

wordpressほど高機能じゃなくていいけど、ちょっとしたことは自由に投稿できるようにしたい、という場合にはちょうどいいんじゃないでしょうか。

なお、DBテーブルのフィールド名なのですが、当初はwordpressを参考にしてdescriptionではなく、contentとしていました。(実際の wordpresspost_contentです)

しかし、途中でIDEにエラーが表示されることになったので、contentという名前は変更することにしました。(エラー内容を読む限り、すでに別の変数としてModelが使っているようです)

まだまだ知らないことは多いですね😅

とにもかくにも、CKEditorはなかなか使い勝手が良かったので、ぜひ皆さんも使ってみてくださいね。

ではでは〜❗

「明晰夢が見れるよう、トレーニング中。
ゼンゼンできない(笑)」

このエントリーをはてなブックマークに追加       follow us in feedly