Laravel+Nuxtの同一オリジンによるSPA開発

LaravelとNuxtを使ってSPAの開発を行うときの一つの形を考えていきたいと思います。 大抵の場合Nuxtはフロントエンド、LaravelはAPIサーバーとして開発を進める場合が多いかと思います。 このフロントエンドからAPIサーバーへの通信ですがクロスドメイン/オリジン通信になる場合、 クライアント・サーバー両方にCORSの対応をしなければなりません。 NuxtならSSRモードのasyncData内での通信や@nuxtjs/proxyを使用して対応している場合も多いかと思います。

ここでは上記とは異なる方法、Laravel・Nuxt間を同一オリジンで通信する形を考えていきたいと思います。

開発環境

開発環境として下記のもので動作確認していきます。

CentOS: 7.8PHP: 7.4.6Node.js: 14.4.0Laravel: 7.14.1Nuxt: 2.12.2

私はDockerで上記環境を構築してVSCodeで開発しています。
Docker+VSCodeによる開発環境の構築(Laravel+Nuxt編)

Laravel、Nuxtのプロジェクトを作成します。以下のディレクトリ構成になるようにします。

composer create-project "laravel/laravel=7" --prefer-dist laranuxt

cd laranuxt
npx create-nuxt-app nuxt

Nuxtはプロジェクト名等を聞いてきますので、とりあえず下記の入力内容で動作確認していきます。(※Axios、Single Page Appは必須)

画像1

上記コマンドで作ったプロジェクトは以下のディレクトリ構成になります。

laranuxtapp
  │  ・
  ├ config
  │  ・
  ├ nuxtpublicresourcesroutes
  │  ・
  │  ・
  ├ .envartisanwebpack.mix.js

Laravel側の開発

Laravelの環境変数として.envファイルに以下の環境変数を定義しておきます。

[laranuxt/.env]
	
+.env


	NUXT_MODE=spa
	NUXT_URL=http://localhost:3000
	NUXT_BASE=/nuxt/
	NUXT_DEBUG=true

LaravelのConfigファイル(nuxt.php)を追加します。

[laranuxtd/config/nuxt.php]
	
+nuxt.php


	<?php

	return [
	    'dir' => base_path(). '/nuxt',
	    'mode' => env('NUXT_MODE', 'spa'),
	    'url' => env('NUXT_URL', 'http://localhost:3000'),
	    'base' => env('NUXT_BASE', '/nuxt/'),
	    'debug' => env('NUXT_DEBUG', false),
	];

web.phpファイルに以下の行を追加します。

[laranuxt/routes/web.php]
	
 web.php


	// Nuxt
	Route::get('/nuxt/{path?}', 'NuxtController@nuxt')->where('path', '.*')->name('nuxt');
	Route::post('/nuxt/{path?}', 'NuxtController@nuxt')->where('path', '.*');

public/.htaccessの設定を変更してURLの末尾スラッシュを付加するようにします。

[laranuxt/public/.htaccess]
	
 .htaccess


	# Redirect Trailing Slashes If Not A Folder...
    #RewriteCond %{REQUEST_FILENAME} !-d	# Comment out
    #RewriteCond %{REQUEST_URI} (.+)/$	# Comment out
    #RewriteRule ^ %1 [L,R=301]		# Comment out
	# ↓ Change
	RewriteCond %{REQUEST_FILENAME} !-f
	RewriteCond %{REQUEST_URI} (.+[^/])$
	RewriteRule ^ %1/ [L,R=301]

Kernel.phpを修正します。APIでセッション、CSRFを使う為(※使わない場合は設定不要)、下記の+部分の行を追加します。

[laranuxt/app/Http/Kernel.php]
	
 Kernel.php


    'api' => [
+	    \App\Http\Middleware\EncryptCookies::class,
+	    \Illuminate\Session\Middleware\StartSession::class,
+	    \Illuminate\View\Middleware\ShareErrorsFromSession::class,
+	    \App\Http\Middleware\VerifyCsrfToken::class,
	    'throttle:60,1',
	    \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

LaravelとNuxtの連携部分となるNuxtController.phpを作成します。

[laranuxt/app/Http/Controllers/NuxtController.php]
	
+NuxtController.php


	<?php

	namespace App\Http\Controllers;

	use Illuminate\Http\Request;


	class NuxtController extends Controller
	{
	    public function nuxt(Request $request)
	    {
	        $content = null;

	        // Dispatch
	        $dispatch = null;
	        if (\Config::get('nuxt.debug') || \Config::get('nuxt.mode') == "ssr" || \Config::get('nuxt.mode') == "universal") {
	            $dispatch = \Config::get('nuxt.url'). \Config::get('nuxt.base');
	        } else {
	            $dispatch = \Config::get('nuxt.dir'). "/dist/";
	        }

	        // Path
	        $path = $request->path;
	        if ($path!=null) {
	            $dispatch = rtrim($dispatch, "/");
	            $dispatch .= "/${path}";
	        };

	        // Parameters
	        $method = $request->method();
	        $params = $request->all();
	        $data = http_build_query($params, "", "&");

	        // Load Content
	        try {
	            if (preg_match("/^https?:\/\//", $dispatch)) {
	                $content = $this->redirect($method, $dispatch, $data);
	            } else {
	                $dispatch = rtrim($dispatch, "/");
	                if (\File::isDirectory($dispatch)) {
	                    $dispatch .= "/index.html";
	                } else {
	                    $dispatch = $this->vuefile($dispatch);
	                }
	                $content = \File::get($dispatch);
	            }
	        } catch (\Exception $ex) {
	            $content = $ex->getMessage();
	        }

	        // Mime Type
	        $mime_type = $this->MimeType($dispatch);
	        if (is_null($mime_type)) $mime_type = "text/html";

	        return response($content)->header('Content-Type', $mime_type);
	    }


	    private function redirect($method, $url, $data)
	    {
	        $content = null;

	        $language = request()->header('Accept-language');
	        $cookie = request()->header('Cookie');

	        $curl = curl_init();

	        if ($method == "POST") {
	            curl_setopt($curl, CURLOPT_URL, $url);
	            curl_setopt($curl, CURLOPT_POST, true);
	            curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
	        } else {
	            curl_setopt($curl, CURLOPT_URL, $url."?${data}");
	            curl_setopt($curl, CURLOPT_HTTPGET, true);
	        }
	        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
	        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
	        curl_setopt($curl, CURLOPT_COOKIE, $cookie);

	        $content = curl_exec($curl);
	        curl_close($curl);

	        return $content;
	    }

	    private function vuefile($filename)
	    {
	        $vuefile = $filename;

	        $pinfo = pathinfo($filename);
	        $ext = array_key_exists("extension", $pinfo) ? strtolower($pinfo['extension']) : null;

	        if (is_null($ext)) {
	            $base_name = $pinfo['basename'];
	            $index_file = preg_replace("/${base_name}$/", "index.html", $vuefile);
	            if (\File::isFile($index_file)) {
	                $vuefile = $index_file;
	            } else {
	                $upper_dir = preg_replace("/\/${base_name}$/", "", $vuefile);
	                $root_dir = \Config::get('nuxt.dir'). "/dist";
	                if (strlen($root_dir) <= strlen($upper_dir)) {
	                    $vuefile = $this->vuefile($upper_dir);
	                }
	            }
	        }

	        return $vuefile;
	    }    

	    private function MimeType($filename)
	    {
	        $mime = null;

	        $mime_types = array(
	            // text, HTML
	            "txt" => "text/plain", 
	            "htm" => "text/html", 
	            "html" => "text/html",
	            "xhtml" => "application/xhtml+xml",
	            "csv" => "text/csv",
	            "xml" => "application/xml",
	            "rss" => "application/rss+xml",
	            "json" => "application/json", 
	            "jsonld" => "application/ld+json",
	            // CSS, Script
	            "css" => "text/css", 
	            "js" => "application/javascript", 
	            "vbs" => "text/vbscript",
	            "php" => "text/html",
	            "cgi" => "application/x-httpd-cgi",
	            // Image 
	            "png" => "image/png", 
	            "jpe" => "image/jpeg", 
	            "jpeg" => "image/jpeg", 
	            "jpg" => "image/jpeg", 
	            "gif" => "image/gif", 
	            "bmp" => "image/bmp", 
	            "ico" => "image/vnd.microsoft.icon", 
	            "tiff" => "image/tiff", 
	            "tif" => "image/tiff", 
	            "svg" => "image/svg+xml", 
	            "svgz" => "image/svg+xml", 
	            // Archive 
	            "zip" => "application/zip", 
	            "rar" => "application/x-rar-compressed", 
	            "exe" => "application/x-msdownload", 
	            "msi" => "application/x-msdownload", 
	            "cab" => "application/vnd.ms-cab-compressed", 
	            "gz" => "	application/gzi",
	            "bz" => "application/x-bzi",
	            "bz" => "application/x-bzip2",
	            // Audio, Movie
	            "mp3" => "audio/mpeg",
	            "m4a" => "audio/acc", 
	            "mpg" => "video/mpeg",
	            "mpeg" => "video/mpeg",
	            "mp4" => "video/mp4",
	            "webm" => "video/webm",
	            "ogg" => "video/ogg",
	            "qt" => "video/quicktime", 
	            "mov" => "video/quicktime",
	            "wav" => "audio/wav",
	            "ra" => "audio/vnd.rn-realaudio",
	            "mid" => "audio/midi",
	            "midi" => "audio/vnd.rn-realaudio",
	            "avi" => "video/x-msvideo", 
	            "swf" => "application/x-shockwave-flash", 
	            "flv" => "video/x-flv", 
	            // Adobe 
	            "pdf" => "application/pdf", 
	            "psd" => "image/vnd.adobe.photoshop", 
	            "ai" => "application/postscript", 
	            "eps" => "application/postscript", 
	            "ps" => "application/postscript", 
	            // MS Office 
	            "doc" => "application/msword", 
	            "rtf" => "application/rtf", 
	            "xls" => "application/vnd.ms-excel", 
	            "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
	            "ppt" => "application/vnd.ms-powerpoint", 
	        ); 

	        // extension --> MIME
	        $pinfo = pathinfo($filename);
	        $ext = array_key_exists("extension", $pinfo) ? strtolower($pinfo['extension']) : null;
	        if (!empty($ext)) {
	            if (array_key_exists($ext, $mime_types)) {
	                $mime = $mime_types[$ext];            
	            } else if (!preg_match("/^https?:\/\//", $filename)) {
	                $finfo = new finfo(FILEINFO_MIME_TYPE);
	                $mime = $finfo->file($filename);
	            } else {
	                $mime = "application/octet-stream";
	            }
	        }

	        return $mime;
	    }

	}

Nuxt側の開発

nuxt.config.jsを編集。以下の+箇所の行を追加します。

[djanuxt/nuxt/nuxt.config.js]
	
 nuxt.config.js


	export default {
	    mode: 'spa',
+	    server: {
+	        port: 3000,
+	        host: '0.0.0.0'
+	    },
+	    router: {
+	        base: '/nuxt/'
+	    },
	    head: {
		・
	    },
	 ・
	 ・
	 ・
	    axios: {
+	        baseURL: '',
	    },
	 ・
	 ・
	}

以上でLaravel・Nuxt連携の実装が出来たので試験用画面を作って動作確認をしてみます。

動作確認用画面の実装

Laravel側にトップページを作成します。 web.phpファイルに以下の+部分の行を追加もしくは変更します。

[laranuxt/routes/web.php]
	
 web.php


	// Nuxt
	Route::get('/nuxt/{path?}', 'NuxtController@nuxt')->where('path', '.*')->name('nuxt');
	Route::post('/nuxt/{path?}', 'NuxtController@nuxt')->where('path', '.*');	
+	// Laravel Top Page
+	Route::get('/', 'IndexController@index')->name('index');

Laravelのコントローラーを作成します。

[laranuxt/app/Http/Controllers/IndexController.php]
	
+IndexController.php


	<?php

	namespace App\Http\Controllers;

	use Illuminate\Http\Request;


	class IndexController extends Controller
	{
	    public function index(Request $request)
	    {
	        return view("index");
	    }
	}

Laravelのビューを作成します。resources/viewsフォルダに以下のindex.blade.php作成・配置。

[laranuxt/resources/views/index.blade.php]
	
+index.blade.php


	<!DOCTYPE html>
	<html>
	<head lang="ja">
	<meta charset="UTF-8">
	<title>Laranuxt</title>
	</head>
	<body>
	    <h1>Laravel</h1>
	    <button id="id_get">Go To Nuxt By GET</button>
	    <button id="id_post">Go To Nuxt By POST</button>

	    <form name="form" method="POST">
	        @csrf
	    </form>
	<script>
	(()=>{
	    document.getElementById("id_get").addEventListener("click", ()=>{
	        document.location.href = "/nuxt";
	    }, false);
	    document.getElementById("id_post").addEventListener("click", ()=>{
	        document.forms['form'].action = "/nuxt/";
	        document.forms['form'].submit();
	    }, false);
	})();
	</script>
	</body>
	</html>

この状態でLaravelからNuxtの呼び出しができます。LaravelとNuxtの開発サーバーを起動してみます。

※laranuxt/artisanの存在するディレクトリで

	php artisan serve --host=0.0.0.0 --port=8000

Nuxtの側は

※laranuxt/nuxt/package.jsonの存在するディレクトリで

	yarn run dev

起動後、
http://localhost:8000にアクセスすれば作成したLaravelのページが開きます。

画像2

http://localhost:3000/nuxtでNuxtのページが表示されます。

画像3

Laravel・Nuxt間を同一オリジンで通信することが目標なので、http://localhost:8000/nuxtでNuxt側にアクセスできるか確認してみます。 作ったDjangoトップページの[Go Nuxt By GET]か[Go Nuxt By POST]ボタン押下してNuxt画面へ遷移させます。 [Go Nuxt By GET]ボタンはHTTP GETメソッドで、[Go Nuxt By POST]ボタンはHTTP POSTメソッド(※CSRFトークン送信)でNuxtに遷移するようにしています。

http://localhost:8000のトップ画面から[..GET]または[...POST]ボタンを押して...

画像4

http://localhost:8000/nuxtでNuxtのページへアクセスできました。

次はNuxt⇒LaravelへAxiosで同一オリジン通信してみます。これも試験用を画面を作ります。 Nuxtトップページに試験用画面へのリンクを作ります。nuxtのindex.vueに下記の+行を追加します。

[laranuxt/nuxt/pages/index.vue]
	
 index.vue


	<template>
		・
		・
	            <a
	                href="https://github.com/nuxt/nuxt.js"
	                target="_blank"
	                class="button--grey"
	            >
	                GitHub
	            </a>
+	            <nuxt-link to="app" class="button--grey">Go to Nuxt App</nuxt-link>
	        </div>
	    </div>
	</div>
	</template>
		・
		・

遷移先のページを作ります。pagesフォルダにapp.vueファイルを作成します。

[laranuxt/nuxt/pages/app.vue]
	
+app.vue


	<template>
	    <div>
	        <h1>Nuxt</h1>
	        <button @click="send('GET')">Axios GET</button>
	        <button @click="send('POST')">Axios POST</button>
	        <button @click="send('PUT')">Axios PUT</button>
	        <button @click="send('DELETE')">Axios DELETE</button>
	        <div>{{ response }}</div>
	        <div v-html="html"></div>
	    </div>    
	</template>

	<script>
	export default {
	    async asyncData({ $axios }) {
	        if (process.server) {
	            const response = await $axios.get("https://www.yahoo.co.jp");
	            return {
	                html: response.data,
	            }
	        }
	    },
	    data() {
	        return {
	            response: null,
	            html: null,
	        }
	    },
	    methods: {
	        async send(method) {
	            // CookieからXSRF Token取得
	            let csrftoken = null;
	            let cookies = document.cookie.split(";");
	            for (const cookie of cookies) {
	                const keyvalue = cookie.split("=");
	                if (keyvalue[0].trim() == "XSRF-TOKEN") {
	                    csrftoken = keyvalue[1];
	                    break;
	                }
	            }
	            // XSRF Token
	            const headers = {
	                "X-XSRF-TOKEN": csrftoken
	            };
	            // 各メソッド別送信
	            if (method == "GET") {
	                const response = await this.$axios.get("/api/get");
	                this.response = response.data;
	            } else if (method == "POST") {
	                const response = await this.$axios.post("/api/add/", {}, { headers: headers });
	                this.response = response.data;
	            } else if (method == "PUT") {
	                const response = await this.$axios.put("/api/edit/", {}, { headers: headers });
	                this.response = response.data;
	            } else if (method == "DELETE") {
	                const response = await this.$axios.delete("/api/remove/", { headers: headers });
	                this.response = response.data;
	            }
	        }
	    }    
	}
	</script>

Laravel側のAPIを実装します。以下のRestController.phpを作成します。

[laranuxt/app/Http/Controllers/Api/RestController.php]
	
+RestController.php


	<?php

	namespace App\Http\Controllers\Api;

	use App\Http\Controllers\Controller;
	use Illuminate\Http\Request;

	use App\Services\RestService;


	class RestController extends Controller
	{
	    public function get(Request $request)
	    {
	        $res = array(
	            "title" => "REST API",
	            "message" => "GET処理",
	            "UNIQID" => $request->session()->get('UNIQID'),
	            "CSRF" => csrf_token(),
	        );
	        return response()->json($res);        
	    }

	    public function add(Request $request)
	    {
	        $res = array(
	            "title" => "REST API",
	            "message" => "POST処理",
	            "UNIQID" => $request->session()->get('UNIQID'),
	            "CSRF" => csrf_token(),
	        );
	        return response()->json($res);        
	    }

	    public function edit(Request $request)
	    {
	        $res = array(
	            "title" => "REST API",
	            "message" => "PUT処理",
	            "UNIQID" => $request->session()->get('UNIQID'),
	            "CSRF" => csrf_token(),
	        );
	        return response()->json($res);        
	    }

	    public function remove(Request $request)
	    {
	        $res = array(
	            "title" => "REST API",
	            "message" => "DELETE処理",
	            "UNIQID" => $request->session()->get('UNIQID'),
	            "CSRF" => csrf_token(),
	        );
	        return response()->json($res);        
	    }

	}

先程作ったLaravelのコントローラーIndexController.phpのindexメソッドにはセッションオブジェクトに値を挿入する処理を追加します。

[laranuxt/app/Http/Controllers/IndexController.php]

 IndexController.php

	
	<?php

	namespace App\Http\Controllers;

	use Illuminate\Http\Request;


	class IndexController extends Controller
	{
	    public function index(Request $request)
	    {
	        $UNIQID = uniqid();
	        $request->session()->flush();
	        $request->session()->regenerate();
	        $request->session()->put('UNIQID', $UNIQID);

	        return view("index");
	    }
	}

そしてLaravelのAPIルーティングの設定(api.php)に以下の行を追加します。

[laranuxt/routes/api.php]
	
 api.php


	// Api
	Route::get("/get", "Api\RestController@get")->name("get");
	Route::post("/add", "Api\RestController@add")->name("add");
	Route::put("/edit", "Api\RestController@edit")->name("edit");
	Route::delete("/remove", "Api\RestController@remove")->name("remove");

では動作確認します。
http://localhost:8000/nuxtを開いて今貼った[Go to Nuxt App]のリンクボタンを押します。

画像5

app.vueのページが開きます。

画像6

AxiosのGET, POST, PUT, DELETEメソッドでLaravel APIに通信してみます。 POST, PUT, DELETEメソッドでの送信はCSRFトークンが必要なのでHTTPヘッダーにセットして送信するようにしています。

通信が成功すると、レスポンスの値が表示されます。

画像7

静的ビルドのSPAモード

最後にNuxtを静的ビルドしてNuxt開発サーバーを停止した状態で動かしてみます。

(※Nuxtの開発サーバーは開発時のみ必要になります。SPAモードでNuxtをビルドしているので本番ではNuxtのサーバーは必要なくなります。 開発時はvueファイル等更新が入ったら自動ビルドして欲しいので使用しています。)

.envファイルのNUXT_DEBUGをfalseにします。

[laranuxt/.env]
	
 .env


	NUXT_DEBUG=false

次にNuxtを静的ビルドします。

※laranuxt/nuxt/package.jsonの存在するディレクトリで

	yarn run build

ビルドが完了したらdistフォルダが出来ています

laranuxt/nuxt/dist

Laravel開発サーバーへアクセスしてみます(http://localhost:8000)。
(※ http://locahost:3000はアクセスできない状態での確認)

画像8

一つのWebサーバー上で動くLaravelとNuxtの同一オリジン連携が出来ました。

画像9

【おまけ】 SSR確認

SSRでも動くか試してみます。

.envのNUXT_MODEをssrにします(universalでもよい)。

[laranuxt/.env]
	
 .env


	NUXT_MODE=ssr

nuxt.config.jsのmodeをuniversalにする。

[laranuxt/nuxt/nuxt.config.js]
	
 nuxt.config.js


	export default {
		mode: 'univarsal',
		  ・
		  ・

Nuxtをビルドする。universalだとクライアント側とサーバー側のビルドが行われます。

※laranuxt/nuxt/package.jsonの存在するディレクトリで

	yarn run build

Nuxtサーバーをプロダクションモードで起動する(yarn run devでもいけるけど)。

起動後Laravelページにアクセスして/nuxt/appまで遷移してください。 spaモードの時と変わらない状態ですが、http://localhost:8000/nuxt/appを直接入力してアクセスするか[F5]でリロードするとYahooのページが表示されます。
※または<nuxt-link to="app"> を <a href="/nuxt/app">に修正

画像11

これはapp.vueのasyncDataがサーバー側で実行されaxiosが走っているからみたいですね

[laranuxt/nuxt/pages/app.vue]
	
 app.vue


   async asyncData({ $axios }) {
       if (process.server) {
           const response = await $axios.get("https://www.yahoo.co.jp");
           return {
               html: response.data,
           }
       }
   },

SSRはこんな動きをするんですね。


以上、Laravel+Nuxtの連携の一つの形を考えてきましたがもっとシンプルな方法もあるかと思います。 簡単に静的ページの領域に静的ビルドしたファイルを配置するだけでも同一オリジンを実現できると思います。 私の場合はNuxtへのアクセスを色々制限したり、レスポンス返却時のHTMLのmetaに値を動的に埋め込んだりする必要があったのでこの形に落ち着きました。 また開発時にもCORSを気にせずにNuxtの開発サーバーを使いたかったということもあります。

もともとこの形は仕事でPHP+Laravel+Nuxtの案件があって実装したものです。 同じ形を必要とする人がいるか分かりませんが、多少なりとも参考になる部分もあるかなと思い技術情報として公開することにしました。 またLaravel+Nuxtの連携を作るに当たって多くの情報を参考にさせて頂きました。 情報を提供してくださった方々に心より感謝いたします。


この記事が気に入ったらサポートをしてみませんか?