⚔️

公開サイトを Laravel で、管理画面を WordPress で作る

2021/05/02に公開

趣旨

エンジニア界隈からは何かと不評な WordPress ですが、一定の需要があることは認めざるを得ません。

何と言っても管理画面のユーザビリティは非常に運用者満足度が高く、
定番プラグイン Advanced Custom Field (特に有料版の Repeater や Flexible Content) を組み合わせた管理画面を見慣れている運用者に対して、
同等の使い勝手をスクラッチで提供するのは並大抵のことではありません。

しかし、複雑な検索機能や認証機能を有するサイトなど、高機能なサイトを開発するとなると、
WordPress で保守しやすいコードを書くのはまた至難の技です。

そこで、

  • 管理画面は WordPress の管理画面を利用する
  • フロント画面は Laravel で作る

という発想に至りました。

勘所

本記事のテーマは大きく3つです。

  • Laravel + WordPress 同居の全体構成
  • WordPress のテーブルを扱いやすくする モデルの実装
  • WordPress のユーザテーブルを用いた 認証 (ログイン) の実装

実際のプロジェクトのコードを加工しながら切り貼りしているため、
そのままでは動作しない箇所等あるかもしれませんが、ご容赦ください。

Laravel + WordPress 同居の全体構成

WordPress はドキュメントルート下に設置する仕様であるため、
Laravel プロジェクトが WordPress を内包する形となります。

├ app/
├ bootstrap/
├ config/
├ database/
├ public/
│ ├ wp-admin/
│ ├ wp-content/
│ │ ├ plugins/
│ │ ├ themes/
│ │ │ ├ mytheme/
│ │ │ └ (略)
│ ├ wp-includes/
│ ├ .htaccess
│ ├ index.php
│ └ (略)
├ resources/
└ (略)

.htaccess および index.php は Laravel のものを設置します。
つまり、WordPress のフロントサイトにルーティングされることはありません。

管理画面は基本的に wp-admin/ 以下にある実体ファイルにアクセスするため、
これで問題ありません。

管理画面のみを動かす場合でも、テーマフォルダは必要です。
基本的にテンプレートファイルは置かず、
カスタム投稿タイプやカスタムタクソノミーの登録、
管理画面関連のフックのみを記述します。

WordPress のテーブルを扱いやすくする モデルの実装

投稿タイプ別のモデル

WordPress では、ほとんどのオブジェクトが wp_posts および wp_terms オブジェクトで管理されています。

しかし、カスタム投稿タイプやカスタムタクソノミーを用いている場合、
これらのテーブルには、性質の異なる複数種類のデータが格納されます。

そこで、同じ wp_posts に格納されるデータであっても、
投稿タイプ (post_type) ごとにモデルを作成し、
グローバルスコープによって必要な投稿タイプのみに絞ることとしました。

<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

abstract class Post extends Model
{
    const POST_TYPE = null;
    const VISIBLE_STATUS = ['publish'];

    protected $table = "wp_posts";
    protected $primaryKey = 'ID';

    //中略

    protected $dates = [
        'post_date',
        'post_date_gmt',
        'post_modified',
        'post_modified_gmt',
    ];
    protected $casts = [
        'ID' => 'integer',
        'post_auther' => 'integer',
        'post_parent' => 'integer',
        'menu_order' => 'integer',
        'post_date'=> 'date:Y-m-d H:i:s',
        'post_date_gmt'=> 'date:Y-m-d H:i:s',
        'post_modified'=> 'date:Y-m-d H:i:s',
        'post_modified_gmt'=> 'date:Y-m-d H:i:s',
    ];

    public static function boot()
    {
        parent::boot();

        static::addGlobalScope('post_type', function (Builder $builder) {
            if (static::POST_TYPE) {
                $builder->where('post_type', static::POST_TYPE);
            }
        });

        static::addGlobalScope('published', function (Builder $builder) {
            $builder->whereIn('post_status', static::VISIBLE_STATUS);
        });
    }

    //後略
<?php
namespace App\Models;

use App\Models\Shared\Post;

class Product extends Post
{
    const POST_TYPE = 'product';

    //後略

どの投稿タイプでも概ね同じような実装となるため、基底クラスを作成しました。

個別のモデルは、定数 POST_TYPE を定義するのみで動作します。

タクソノミー別のモデル

タクソノミー(ターム)も同様なのですが、
こちらは wp_terms と wp_term_taxonomies の2テーブルに分かれているため、
扱いが少々ややこしいです。

投稿と直接リレーションをもつのは wp_term_taxonomies であるため、
こちらを基底クラスとして作成します。

プロパティ取得の便宜上 wp_terms 用のモデルも作成しましたが、
アクセサ経由でバイパスするため、個々のモデルから触れることはありません。

<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

class Term extends Model
{
    protected $table = "wp_terms";
    protected $primaryKey = 'term_id';

    protected $casts = [
        'term_id' => 'integer',
    ];

    //後略
<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

abstract class TermTaxonomy extends Model
{
    const TAXONOMY = null;

    protected $table = "wp_term_taxonomy";
    protected $primaryKey = 'term_taxonomy_id';

    //中略

    public static function boot()
    {
        parent::boot();

        static::addGlobalScope('post_type', function (Builder $builder) {
            if (static::TAXONOMY) {
                $builder->where('taxonomy', static::TAXONOMY);
            }
        });
    }

    // term へのリレーション
    public function term()
    {
        return $this->belongsTo(Term::class, 'term_id');
    }

    //中略

    // name : ターム名
    public function getNameAttribute()
    {
        return $this->term->name;
    }

    // slug : スラッグ
    public function getSlugAttribute()
    {
        return $this->term->slug;
    }

    //後略
}

<?php
namespace App\Models;

use App\Models\Shared\TermTaxonomy;

class ProductCategory extends TermTaxonomy
{
    const TAXONOMY = 'product-category';

    public function products()
    {
        return $this->belongsToMany(Product::class, 'wp_term_relationships', 'term_taxonomy_id', 'object_id');
    }

    //後略

こちらも Post 系同様、基底クラスである TermTaxonomy を継承して、
定数 TAXONOMY を定義するだけで動作します。

リレーションも問題なしです。

投稿→ターム 側のリレーションは、以下のようになります。

<?php
namespace App\Models;

use App\Models\Shared\Post;

class Product extends Post
{
    //中略

    public function categories()
    {
        return $this->belongsToMany(ProductCategory::class, 'wp_term_relationships', 'object_id', 'term_taxonomy_id');
    }

カスタムフィールドの扱い

WordPress のテーブル設計でもうひとつ厄介なのは、
カスタムフィールド (Post Meta) ですね。

WordPress のテーブルは基本的にカラム拡張不可のため、
デフォルトカラムに入らないプロパティは全て wp_postmeta に key/value 形式で格納されています。

これを1項目ごと条件つきのリレーション貼るのは面倒、かつパフォーマンスが悪いので、
基底クラスで Eager ロードして、モデルのプロパティにぶちこむことにしました。

<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

class PostMeta extends Model
{
    protected $table = "wp_postmeta";
    protected $primaryKey = 'meta_id';

    protected $hidden = [];
    protected $appends = [];

    protected $casts = [
        'meta_id' => 'integer',
        'post_id' => 'integer',
    ];
}
<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

abstract class Post extends Model
{
    //中略

    protected $metaadta = null;

    protected $with = ['metas'];

    //中略

    public function metas()
    {
        return $this->hasMany(PostMeta::class, 'post_id');
    }

    //注:改良前
    public function getMetaAttribute()
    {
        if ($this->metadata === null) {
            $this->metadata = $this->meta->mapWithKey(function ($meta) {
                return [$meta['meta_key'] => $meta['meta_value']];
            });
        }

        return $this->metadata;
    }

    //後略

初回にカスタムフィールドにアクセスした際に、
PostMeta のデータを key => value 形式の Collection に変換して、
プロパティ $metadata に格納しています。

これで、コントローラや Blade で以下のようにアクセスできるようになります。

    $product = Product::find($id);

    //カスタムフィールド
    $product->meta->code;
    $product->meta->price;

しかし、これにはひとつ問題があります。

カスタムフィールドはテーブルのカラムと違い、
全ての投稿データに対してキーが存在するとは限りません。

特に、途中で機能改修によりカスタムフィールドが追加された場合です。

この場合、$product->meta に該当のプロパティが存在せず、
エラーとなってしまいます。

optional() ヘルパでの対応も可能ですが、
利用頻度が高いだけに面倒なので、
以下のように、データ保持用のクラスを噛ませました。

<?php
namespace App\Models\Shared;

use Illuminate\Support\Collection;

class MetaData
{
    private $properties = [];

    public function __construct(Collection $postmetas)
    {
        foreach ($postmetas as $postmeta) {
            $this->properties[$postmeta->meta_key] = $postmeta->meta_value;
        }
    }

    public function __get($key)
    {
        return $this->properties[$key] ?? null;
    }
}
<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

abstract class Post extends Model
{
    //中略

    public function getMetaAttribute()
    {
        if ($this->metadata === null) {
            $this->metadata = new MetaData($this->metas);
        }

        return $this->metadata;
    }

    //後略

これで、存在しないカスタムフィールドにアクセスしても、
エラーは発生せず null が返るようになります。

画像の扱い

アイキャッチ画像に代表される WordPress のメディアアップローダーは、
画像アップロード時に、テーマファイル等で指定したサイズにトリミングし、
複数のサムネイルを自動的に生成してくれます。

Web サイトでは特に利用頻度が高いので、これも扱いやすいようにしておきます。

<?php
namespace App\Models\Shared;

class File extends Post
{
    const POST_TYPE = 'attachment';
    const VISIBLE_STATUS = ['inherit'];

    //中略

    public function getUrlAttribute()
    {
        return $this->guid;
    }

    public function url($size = 'thumbnail')
    {
        //メタデータが存在しないなら、オリジナルのURLを返す
        $attachment_metadata_serialized = $this->meta->_wp_attachment_metadata;
        if (!$attachment_metadata_serialized) {
            return $this->guid;
        }

        $attachment_metadata = unserialize($attachment_metadata_serialized);
        if (!$attachment_metadata) {
            return $this->guid;
        }

        //指定サイズのデータが存在しないなら、オリジナルのURLを返す
        if (!isset($attachment_metadata['sizes'][$size])) {
            return $this->guid;
        }

        //指定サイズのURLを返す
        return str_replace(basename($this->guid), $attachment_metadata['sizes'][$size]['file'], $this->guid);
    }
}
<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Model;

abstract class Post extends Model
{
    //中略

    //アイキャッチ画像
    public function getThumbnailAttribute()
    {
        if ($this->meta->_thumbnail_id) {
            return File::find($this->meta->_thumbnail_id);
        }

        return null;
    }

    //後略
}

添付ファイルも post_type = attachment で wp_posts に格納されますが、
ステータスが inherit になっているので、注意が必要です。

これで、Blade では

    @if ($product->thumbnail)
        <img src="{{ $product->thumbnail->url }}">
    @endif

あるいはサイズを指定して、

    @if ($product->thumbnail)
        <img src="{{ $product->thumbnail->url('medium') }}">
    @endif

のように扱うことができます。

また、例えばカスタムフィールド main_image に画像IDが格納されているような場合でも、

モデルで、

    public function getMainImageAttribute()
    {
        if ($this->meta->main_image) {
            return File::find($this->meta->main_image);
        } else {
            return null;
        }
    }

としておけば、

    @if ($product->main_image)
        <img src="{{ $product->main_image->url }}">
    @endif

のように、シンプルに扱うことができます。

その他TIPS

ここから先は、各プロジェクトごとに変わってくると思いますが、

今回のプロジェクトの場合、リプレース元 (WordPressで構築) のURLを踏襲するため、
URLにはもっぱらIDではなくスラッグ (post_name / slug) が入ります。

そのため、スラッグをキーとしてデータを取得するケースが多かったので、
基底クラス Post および TermTaxonomy に、
以下のようなデータ取得用メソッドを追加しました。

    //スラッグ (post_name) で取得
    public static function findByPostNameOrFail(string $post_name)
    {
        $post = static::where('post_name', urlencode($post_name))->first();

        if ($post === null) {
            abort(404);
        }

        return $post;
    }
    // スタッグで取得
    public static function findBySlugOrFail(string $slug)
    {
        $term_ids = Term::where('slug', urlencode($slug))->get()->pluck('term_id');
        if ($term_ids->count() === 0) {
            abort(404);
        }

        $term_taxonomy = static::whereIn('term_id', $term_ids)->first();
        if ($term_taxonomy === null) {
            abort(404);
        }

        return $term_taxonomy;
    }

注意点としては、Laravel のルーティングは日本語URLをデコードしてくれるため、
再度エンコードして検索する必要がある、ということろですね。

また、いわゆるタームアーカイブで、
「特定のタームに属する投稿の一覧を取得する」というケースが非常に多かったので、
投稿の基底クラス Post に以下のようなスコープを追加しました。

<?php
namespace App\Models\Shared;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

abstract class Post extends Model
{
    //中略

    //ターム指定
    public function scopeInTerm(Builder $query, TermTaxonomy $term)
    {
        $post_ids = TermRelationship::where('term_taxonomy_id', $term->term_taxonomy_id)->get()->pluck('object_id');

        $query->whereIn('ID', $post_ids);

        return $query;
    }

    //後略

コントローラでは、以下のように使用します(実際には UseCase クラスに切り出したりしています)。

    public function category(string $slug)
    {
        $category = ProductCategory::findBySlugOrFail($slug);

        $products = Product::inTerm($category)->paginate(config('pager.product'));

        return view('product.category', [
            'products' => $products,
        ]);
    }

なんとか Laravel っぽくなってきましたね。

WordPress のユーザテーブルを用いた 認証 (ログイン) の実装

WordPress で会員制サイト等を開発する場合、
会員データも (管理者ユーザと同じ) wp_users で管理するのが、
一応のセオリーだと思います。

新規開発であれば、
会員データを (WordPressのテーブルではない) 独自テーブルにもつという
選択肢もアリだと思いますが、

今回はリプレース案件であり、
wp_users に既存会員データが大量に存在したため、
wp_users のユーザデータでログインできるようにしました。

認証用テーブルの対応

まず、wp_users の認証用カラムは user_email と user_pass であり、
Laravel のデフォルトと異なっています。
これは、ユーザーモデルとログインコントローラの変更で比較的容易に対応できます。

<?php
namespace App\Models;

//中略

class User extends Model implements
    AuthenticatableContract,
    AuthorizableContract,
    CanResetPasswordContract
{
    use Authenticatable, Authorizable, CanResetPassword, MustVerifyEmail;

    protected $table = "wp_users";
    protected $primaryKey = "ID";

    //中略

    public function getAuthPassword()
    {
        return $this->user_pass;
    }
}
<?php
namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    use AuthenticatesUsers;

    //中略

    public function username()
    {
        return 'user_email';
    }
}

パスワードハッシュ方式の対応

続いてネックになるのは、ハッシュ化されたパスワードです。

Laravel はパスワードのハッシュ方式として、
bcrypt, argon, argon2id に対応していますが、

WordPress のパスワードハッシュには、
PHPass (Portable PHP password hashing) というライブラリが使用されているため、
これに自力で対応させる必要があります。

Hasher クラスの作成

まずは、PHPass を Composer でインストールします。

{
    "name": "laravel/laravel",
    "type": "project",
    "description": "The Laravel Framework.",
    "keywords": ["framework", "laravel"],
    "license": "MIT",
    "require": {
        //中略
        "hautelook/phpass": "^1.1"
    }
    //後略
}

そしてパスワードハッシュを担う Hasher クラスを、
所定の Interface に則って実装します。

<?php
/**
 * 注:info() と needsRehash() は調べきれていないため適当です(汗
 */
namespace App\Auth;

use Hautelook\Phpass\PasswordHash;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;

class WordpressHasher implements HasherContract
{
    public function info($hashedValue)
    {
        return password_get_info($hashedValue);
    }

    public function make($value, array $options = [])
    {
        $wp_hasher = new PasswordHash(8, true);
        return $wp_hasher->HashPassword(trim($value));
    }

    public function check($value, $hashedValue, array $options = [])
    {
        $wp_hasher = new PasswordHash( 8, true );
        return $wp_hasher->CheckPassword( $value, $hashedValue );
    }

    public function needsRehash($hashedValue, array $options = [])
    {
        return false;
    }
}

UserProvider クラスの作成

続いて UserProvider クラスを作成します。

UserProvider クラスは、Laravel のユーザ認証の中核を担っているクラスです。

デフォルトで使用されている EloquentUserProvider を継承して作成します。

<?php
namespace App\Auth;

use App\Models\Shared\UserMeta;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;

class WordpressUserProvider extends EloquentUserProvider
{
    public function filter_valid_user($user)
    {
        //存在チェック
        if (!$user) {
            return null;
        }

        //権限チェック
        $meta = UserMeta::where('user_id', $user->ID)->where('meta_key', 'wp_capabilities')->first();
        if (!$meta) {
            return null;
        }
        $capabilities = unserialize($meta->meta_value);
        if (empty($capabilities['subscriber'])) {
            return null;
        }

        return $user;
    }

    public function retrieveByCredentials(array $credentials)
    {
        $user = parent::retrieveByCredentials($credentials);
        return $this->filter_valid_user($user);
    }

    public function retrieveById($identifier)
    {
        $user = parent::retrieveById($identifier);
        return $this->filter_valid_user($user);
    }

    public function retrieveByToken($identifier, $token)
    {
        $user = parent::retrieveByToken($identifier, $token);
        return $this->filter_valid_user($user);
    }

    public function validateCredentials(UserContract $user, array $credentials)
    {
        $plain = $credentials['password'];

        return $this->hasher->check($plain, $user->getAuthPassword());
    }
}

ここではユーザが購読者権限 (subscriber) であることをチェックするため、
wp_usermeta の "wp_capabilities" の値を確認しています。

自作クラスの適用

ここまでできれば、あとは依存性注入と config 類です。
Laravel のお作法的なところなので、
よくわからなければ読み飛ばしてください。
詳細説明は 公式ドキュメント に譲ります。

まずは自作の Hasher を注入するためのサービスプロバイダを作成します。

<?php
namespace App\Providers;

use App\Auth\WordpressHasher;
use Illuminate\Support\ServiceProvider;

class WordpressServiceProvider extends ServiceProvider
{
    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton('wp_hash', function () {
            return new WordpressHasher();
        });
    }

    /**
     * Get the services provided by the provider.
     *
     * @return array
     */
    public function provides()
    {
        return ['wp_hash'];
    }
}

これを config/app.php に追加します。

    //前略

    'providers' => [
        //中略
        App\Providers\WordpressServiceProvider::class,
    ],

    //後略

そして、 AuthServiceProvider で、自作の UserProvider を登録します。
この時、先に登録した Hasher クラスを注入します。

<?php

namespace App\Providers;

use App\Auth\WordpressUserProvider;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;

class AuthServiceProvider extends ServiceProvider
{
    //中略

    public function boot()
    {
        $this->registerPolicies();

        //追加
        Auth::provider('wpuser_provider', function ($app, array $config) {
            return new WordpressUserProvider($app['wp_hash'], $config['model']);
        });

        //後略
    }
}

そして最後に config/auth.php です。

    //前略

    'providers' => [
        'users' => [
            //'driver' => 'eloquent',
            'driver' => 'wpuser_provider',
            'model' => App\Models\User::class,
        ],
    ],

    //後略

これで、wp_users テーブルに存在するユーザで
ログインできるようになりました。

残る課題

まだ開発途上なので、色々問題は出てくるかもしれません。
その都度本記事に追記していきます。

現時点で浮上している課題は、以下のようなものがあります。

管理者プレビュー

取得対象を post_status = publish に絞っているため、
管理者が、下書きや非公開で保存した投稿のプレビューができません。

プレビューURLは通常、公開用のURLとは異なるので、
専用のルーティングを作成する必要があります。

また、この際 Laravel から、
WordPress 管理画面へのログインセッションを取得する必要があります、

管理者の閲覧中に Admin Bar が出ない

こちらも WordPress 管理画面へのログインセッションを取得できれば、
全く同じとはいかずとも、
必要最低限の機能を独自実装することはできると思います。

雑感など

今回の発端となったのは BtoB の ECサイトで、
ショッピングカート、複数の決済機能、複数の価格計算ロジック、会員マイページ、見積書や領収書の発行等、
そこそこ機能の多いサイトのリニューアルです。

リプレース元は WordPress で、繰り返された機能の継ぎ足しにより、保守困難な状態になっていました。

しかし運用者は、商品登録のみならず、記事や特集ページの制作に積極的で、
これが原動力となって集客に成功しているため、
使い慣れた WordPress の管理画面を捨てる提案は困難でした。

しかしよくよく考えてみると、
ユーザ (運用者) にとって WordPress を選択する理由は、
専ら管理画面だけであることに気づきました。

少々無茶なチャレンジかとも思いましたが、
モデル周りを整備することで、
いつもどおりの Laravel 実装にかなり近づけることができました。

Discussion