【NativePHP】Laravel→スマホアプリ化して「セルフオーダー機能」をつくる

こんにちは!
九保すこひです(フリーランスのITコンサルタント、エンジニア)

さてさて、いつもお世話になっているクライアントさんとミーティングするとき、密かに楽しみにしていることがあります。

それは・・・・・・

新しいテクノロジーの情報交換👍

です。

というのも、開発の世界は広大すぎて全ての情報をキャッチアップするのは到底無理だからですね(笑)

そして、先日とあるクライアントさんから教えていただいたのが、

NativePHPが本番リリースされたよ!

というものです。

NativePHPとは、PHP(Laravel)でデスクトップ・モバイルアプリがつくれる夢のようなテクノロジーです。

※その昔、このブログでも記事にしてみたのですが当時はアルファ版で、その後ニュースを聞くことがなかったため、頓挫したものと思い込んでいました(しかも結構前に本番リリースされたらしいです)

📝 参考記事:NativePHP で簡単なメモ帳アプリつくってみた

そして、じゃあモバイルアプリでもつくろうか考えた結果、ファミレスの「セルフオーダー機能」をつくることにしました。

ということで!

今回はNativePHPをつかって「セルフオーダー機能」をつくります。
以下のような方は、ぜひ最後まで読んでくださいね。

  • 「Laravelでモバイル(Android&iPhone)アプリがつくりたい!」
  • 「FlutterやReact Nativeを試したけど、クセが強すぎて無理😫」
  • 「いまあるLaravelアプリをモバイル化したい!」
  • 「デスクトップアプリをつくりたいけど、新しい言語を勉強するのはちょっと・・・」
  • 「自分のアプリを簡単に配布したい」
  • 「とにかく新しい知識を吸収してスキルアップしたい!」

「カナダで会った友人と再会!
気分があの頃に戻って楽しかった😊👍」

実現する方法

今回の実装では、大きく以下2つのパートをつくります。

  • NativePHP:料理の選択(今回はAndroidアプリ)
  • Laravel:APIとして料理情報と注文の保存

つまり、NativePHPからAPIでデータのやりとりする形ですね。

【Laravel】料理APIをつくる(3ステップ)

では、純正のLaravel 12.xでAPIをつくります。

1. APIを有効にする

プロジェクトをつくったら、以下のコマンドを実行してください。

php artisan install:api

※これで「routes/api.php」が作成されます。
※今回はsanctumとか認証は使いません。

2. DBまわりをつくる

同じく以下のコマンドを実行してください。

php artisan make:model Product -ms

モデルとマイグレーション、Seederが作成されるので中身を変更します。

database/migrations/****_**_**_******_create_products_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('料理名');
$table->unsignedInteger('price')->comment('価格');
$table->timestamps();
});

}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products');
}
};

database/seeders/ProductSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Product;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;

class ProductSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$productItems = [
[
'name' => '極上バター香るトリプルチーズハンバーグステーキ',
'price' => 1700,
],
[
'name' => 'まろやか特製タルタルソースのチキン南蛮プレート',
'price' => 1300,
],
[
'name' => '香ばし白神あわび茸ソテー添えシャリアピンハンバーグ',
'price' => 1800,
],
[
'name' => '50種スパイス香る本格スープカレーとナンセット',
'price' => 1400,
],
[
'name' => 'とろとろ卵と濃厚デミグラスのオムライスグラタン風',
'price' => 1500,
],
];

foreach ($productItems as $productItem) {

$product = new Product;
$product->name = $productItem['name'];
$product->price = $productItem['price'];
$product->save();

}

}
}

database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;

/**
* Seed the application's database.
*/
public function run(): void
{
$this->call([
ProductSeeder::class,
]);

}
}

これで以下コマンドを実行すると、DBまわりは完成です。

php artisan migrate:fresh --seed

実際のテーブルはこうなりました。

3. コントローラーをつくる

以下のコマンドを実行してください。

php artisan make:controller Api\\ProductController

すると、ファイルが作成されるので中身を変更します。

app/Http/Controllers/Api/ProductController.php

<?php

namespace App\Http\Controllers\Api;

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

class ProductController extends Controller
{
public function index()
{
// 本来はAPIリソースで出していい情報だけ返すようにしましょう!
return Product::get();
}

}

あとはルートですね。

routes/api.php

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\ProductController;

Route::prefix('product')->group(function () {
// 料理データを取得
Route::get('/', [ProductController::class, 'index']);
});

アクセスするとこんなカンジになります。

※ちなみに、ローカル環境へのアクセス(例:https://192.168.0.1/api/product)はアプリ側で拒否されるようでした。おそらく、ちゃんとした証明書が必要になりそうです。なので、結局API側は自分の借りているウェブサーバーに設置しました。

【NativePHP】モバイルアプリをつくる(7ステップ)

さぁ、ここからが本題のNativePHPを使う部分です!

1. ライセンスを用意する

残念ながら2025/10/26現在、NativePHPは無料で使うことはできません。

ただし、2025/10/17日からプランが変更され、Bifrostと呼ばれるビルド&デプロイサービスに登録するとライセンスを1つ無料ゲットできます。

なので、今回はBifrostの一番安い(といっても月額19ドル≒3000円ですが😅)に登録して作業を進めます!

こちらの登録ページからユーザー登録をします。

チーム名を入力して送信。

プランを選んで送信。

※前述のとおり「Monthly」の「Loki」が一番やすいです。月額なので注意!年払いなら2ヶ月割引だそうです。

すでにライセンスをもってるか聞かれますが、もちろん今回はもっていないので「Skip for now」をクリック。

準備はいいか聞かれるので「Complete Setup」をクリック。

すると、支払い情報の入力になります。
以下はクレジットカードですが、Amazon Payとかも使えるみたいですね。

機械じゃないことを証明する。

はい!
これで登録が完了し、ライセンスキー(メールアドレスとコード)を取得することができました。

【重要】このキーは、NativePHPcomposerでインストールするときに必要になるので、大切に保管しておいてください。

2. composerでNativePHPパッケージをインストールする

NativePHPといってもベースはLaravelです。

まずはLaravelで新しいプロジェクトをつくってください(今回は、Vue 3 + Inertia + TypeScript + SQLiteで話を進めます)

※NativePHPはPHP 8.3以上が必須です。

では、本家のページを元にインストールを進めます。

NativePHPパッケージのインストールに必要なので、composer.jsonを開いて以下の部分を追加してください。

composer.json

{
// ここが元からある部分(省略)

"repositories": [
{
"type": "composer",
"url": "https://nativephp.composer.sh"
}
]

}

そして、インストールしたLaravelのルートフォルダで、以下コマンドを実行します。

composer require nativephp/mobile

すると、

  • Username:さっきのメールアドレス
  • Password:同じくライセンスコード

を聞かれるので、入力して実行します。

あとは、Enterキーを連打してデフォルト設定でインストールすればいいでしょう。

はい!
これでNativePHPのパッケージがインストールされました。

3. Artisanコマンドを実行して、NativePHP本体をインストールする

インストールコマンドを実行する前に.envに以下3つの情報を追加しておきます。

  • NATIVEPHP_APP_ID:他のアプリと重複しないID(注:ハイフンつかえません!アンダースコアはOK。例:com.sukohi.mobile_self_checkout
  • NATIVEPHP_APP_VERSION:DEBUG(毎回変更したコードでビルドされる)
  • NATIVEPHP_APP_VERSION_CODE:バージョン番号

なので、以下のように追加しました。

.env

# 省略

NATIVEPHP_APP_ID=(あなたのアプリIDをここへ)
NATIVEPHP_APP_VERSION="DEBUG"
NATIVEPHP_APP_VERSION_CODE="1"

では以下コマンドを実行してNativePHP本体をインストールしましょう。

php artisan native:install

すると、以下のように聞かれるので、多言語化できるようにYesにしておきます。

これでNativePHP本体のインストールが完了しました!

※これで、nativephpフォルダが作成されます。

ちなみに本家ページによると、NativePHPパッケージをインストールするごとにnativephpフォルダは変更になるらしいです。

なので「gitで共有しないようにしたほうがいいよ」と書いてます。
ということで.gitignoreに追加しておきましょう。

.gitignore

/nativephp

4. Android Studioをインストールして実行環境をつくる

NativePHPアプリを起動してみたいところですが、まだAndroidの実行環境がないのでうまくいきません。

先にAndroid Studioをインストールしてください(私の環境はLinuxなので、インストール方法は割愛します。本家ページをご覧ください)

そして、SDK Managerを開いてAPIレベルが33以上のSDKをインストールします。

※私の場合、すでに36がインストールされていました。

ちなみに以下が注意点まとめです。

  • windowsの場合のみ、7zipのインストールが必須。
  • Java Development Kit (JDK)を独自にインストールする必要があるかもしれません(Android Studioが自動でインストールしなくなったため。いろいろ例の揉め事があったたからでしょうか…🤔)

※詳しくは本家ページ(英語)を確認してください。

そして、SDK Toolsタブの中を確認し、以下2つがインストールされていることを確認します。

  • Android SDK Build-Tools
  • Android SDK Platform-Tools

また、NativePHPJavaAndroid SDKにアクセスできるようパスを通しておきます。OSによって違うので、詳しくは本家ページをご覧ください。

では、Device Managerを開いてエミュレータを起動します。

ちなみにこの状態で以下2つのコマンドが実行できることを確認しておきましょう。

  • adb devices:エミュレータが起動してることが確認できるはずです。
  • java -version:javaコマンドが実行できるか確認します

※それぞれエラーがある場合は「adb」もしくは「java」のインストールができていません。もしくはjava(jdk)のバージョンが正しくありません。

では、NativePHPを起動します。
以下コマンドを実行してください。

php artisan native:run

すると、以下のような選択肢がでるので「Debug」で実行します。

はい❗
Laravelの起動画面がAndroid Studioのエミュレータに表示されました😊

すごいですね✨

ただし、NativePHP + エミュレータだとどうしても動作が重くなってしまう(私の場合はphpstormも動かすので特に)ので、実機で確認できるようにするといいでしょう。

方法はGoogleが用意してくれているので、本家ページ(日本語)を確認してください。

USBデバッグ、もしくはWi-Fiデバッグが使える状態で実行すると、スマホでLaravelのウェルカムページを見ることができます。

※Android Studioを閉じても問題ありませんでした👍

5. NativePHPで料理を注文する部分をつくる

ではNativePHPLaravel)開発です!

まずは、JavaScriptパッケージをインストールします。
Lodashは、いろんな便利機能がつまったパッケージです。

npm i lodash --save-dev

では、実際のコードです。

routes/web.php

<?php

use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('/', function () {
return Inertia::render('Welcome', []);
});

resources/js/Pages/Welcome.vue

<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import axios from 'axios';
import _ from 'lodash';
import { computed, onMounted, ref } from 'vue';

// Constants
const API_BASE_URL = 'https://api.mobile-self-checkout.test/api'; // あなたのAPIのベースURLに置き換えてください

// Mode
type Mode = 'order' | 'cart';
const mode = ref<Mode>('order');

// Order
interface OrderItem {
product: ProductItem;
quantity: number;
}
const orders = ref<OrderItem[]>([]);
const orderQuantity = ref(0);
const onAddToCart = () => {
if (currentProduct.value) {
const orderItem = orders.value.find(
(item) => item.product.id === currentProduct.value!.id,
);
if (orderItem) {
orderItem.quantity = orderQuantity.value;
}
currentProduct.value = null;
}
};
const filteredOrders = computed(() => {
return orders.value.filter((item) => item.quantity > 0);
});
const orderTotalQuantity = computed(() => {
return _.sumBy(orders.value, (item) => item.quantity);
});
const orderTotalPrice = computed(() => {
return _.sumBy(orders.value, (item) => item.product.price * item.quantity);
});

// Product
interface ProductItem {
id: number;
name: string;
price: number;
}
const products = ref<ProductItem[]>([]);
const currentProduct = ref<ProductItem | null>(null);
const onProductSelect = (product: ProductItem) => {
const orderItem = orders.value.find(
(item) => item.product.id === product.id,
);
if (orderItem) {
orderQuantity.value = orderItem.quantity;
} else {
orders.value.push({
product: product,
quantity: 0,
});
orderQuantity.value = 1;
}
currentProduct.value = product;
};
onMounted(async () => {
const url = `${API_BASE_URL}/product`;
const response = await axios.get(url);
if (response) {
products.value = response.data;
}
});

// Submit
const onSubmit = async () => {
if (filteredOrders.value.length === 0) {
alert('注文が空です。');
return;
}
const url = `${API_BASE_URL}/order`;
const data = {
orders: filteredOrders.value,
};
const response = await axios.post(url, data);
if (response.data.result) {
alert('注文が確定しました。');
orders.value = [];
mode.value = 'order';
} else {
alert('注文に失敗しました。再度お試しください。');
}
};
</script>

<template>
<Head title="セルフオーダー" />
<div class="min-h-screen bg-gray-50">
<div class="p-8 pb-0">
<div class="text-center">
<h1
class="mb-7 text-3xl font-bold text-gray-800"
style="letter-spacing: 0.03rem"
>
セルフオーダー
</h1>
<!-- 注文モード -->
<div v-if="mode === 'order'">
<div class="fixed bottom-2 left-0 mb-7 ml-8">
<button
type="button"
class="flex items-center justify-center gap-1 rounded-full bg-pink-700 px-4 py-2 text-sm text-white"
@click="mode = 'cart'"
>
カート
<div
class="h-5 w-5 rounded-full bg-white text-pink-700"
>
{{ orderTotalQuantity }}
</div>
</button>
</div>
<div
class="mb-1 flex items-center justify-between bg-pink-700 p-4 font-bold text-white"
>
注文を選択してください
</div>
<a
href="#"
class="flex items-center justify-between gap-10 border-b p-2 text-left"
v-for="(product, id) in products"
:key="id"
@click.prevent="onProductSelect(product)"
>
<div class="text-base font-bold">
{{ product.name }}
</div>
<div class="text-nowrap text-right">
<span class="font-serif text-xl">{{
product.price.toLocaleString('ja-JP')
}}</span
>円<br />
</div>
</a>
</div>
<!-- カートモード -->
<div v-else-if="mode === 'cart'">
<div class="fixed bottom-2 left-0 mb-7 ml-8">
<button
type="button"
class="rounded-full bg-gray-400 px-4 py-2 text-sm text-white"
@click="mode = 'order'"
>
戻る
</button>
</div>
<div
class="relative mb-1 bg-pink-700 p-4 text-left font-bold text-white"
>
カートの中身
<div class="absolute right-5 top-2.5 flex items-center">
<span class="text-3xl">{{
orderTotalPrice.toLocaleString('ja-JP')
}}</span>
<div class="pl-1 pt-2">
<small> 円</small>
</div>
</div>
</div>
<a
href="#"
class="flex items-center justify-between gap-10 border-b p-2"
v-for="(order, id) in filteredOrders"
:key="id"
@click.prevent="onProductSelect(order.product)"
>
<div class="text-left text-base font-bold">
{{ order.product.name }}
</div>
<div class="text-nowrap pr-1 text-right">
<span class="font-serif text-xl">{{
order.product.price.toLocaleString('ja-JP')
}}</span
>円 ×
<span class="font-serif text-xl">{{
order.quantity
}}</span>
</div>
</a>
</div>
</div>
</div>

<!-- 個数選択モーダル -->
<div
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
v-if="currentProduct"
>
<div class="w-96 rounded bg-white p-8 text-center shadow-lg">
<h2 class="mb-4 text-2xl font-bold">
{{ currentProduct.name }}
</h2>
<p class="mb-6 text-sm text-gray-500">
個数を選択してください。
</p>
<div class="flex items-center justify-center">
<span class="text-3xl">{{ currentProduct.price.toLocaleString('ja-JP') }}</span>
<small class="pl-1 pt-1.5">円</small>
</div>
<div class="mb-10 mt-3 flex items-center justify-between">
<button
type="button"
class="mr-4 rounded-full border bg-gray-200 px-4 py-2"
:disabled="orderQuantity <= 1"
@click="orderQuantity--"
>
-
</button>
<input
type="number"
min="1"
v-model="orderQuantity"
class="w-20 rounded border border-gray-200 p-2 text-center"
readonly
/>
<button
type="button"
class="ml-4 rounded-full border bg-gray-200 px-4 py-2"
@click="orderQuantity++"
>
+
</button>
</div>
<div class="flex gap-3">
<button
class="mr-4 w-full rounded bg-gray-400 px-4 py-2 text-white"
@click="currentProduct = null"
>
キャンセル
</button>
<button
class="w-full rounded bg-pink-700 px-4 py-2 text-white"
@click="onAddToCart"
>
決定する
</button>
</div>
</div>
</div>
<div class="fixed bottom-0 right-0 z-20 mb-7 mr-8 text-center">
<button
class="rounded-full bg-sky-500 px-6 py-3 text-xl font-bold text-white"
@click="onSubmit"
>
<small>注文を</small>確定する
</button>
</div>
</div>
</template>

これを実行するとこうなります。

6. 注文データを保存する部分をつくる

さて、ここからはもう一度APIの方での作業です!
注文情報をデータベースに保存する機能ですね。

以下コマンドでコントローラーをつくってください。

php artisan make:model Order -m

すると、モデルとマイグレーションが作成されるので、中身をそれぞれ変更しましょう。

database/migrations/****_**_**_******_create_orders_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->uuid()->comment('注文UUID');
$table->unsignedBigInteger('product_id')->comment('商品ID');
$table->unsignedBigInteger('product_name')->comment('商品名');
$table->unsignedInteger('price')->comment('価格');
$table->unsignedInteger('quantity')->comment('数量');
$table->timestamps();

$table->foreign('product_id')->references('id')->on('products');
});

}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('orders');
}
};

※ちなみに、価格や商品名が変わる可能性もあるので、当時のデータを残せるようにしています。

以下コマンドでDBを初期化しておきましょう。

php artisan migrate:fresh --seed

そして、コントローラーです。

php artisan make:controller Api\\OrderController

中身を変更します。

app/Http/Controllers/Api/OrderController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class OrderController extends Controller
{
public function store(Request $request)
{
// バリデーションは省略しています

$uuid = Str::uuid()->toString();

DB::beginTransaction();

try {

foreach ($request->orders as $orderItem) {

$targetProduct = Product::find($orderItem['product']['id']);

$order = new Order;
$order->uuid = $uuid;
$order->product_id = $targetProduct->id;
$order->product_name = $targetProduct->name;
$order->price = $targetProduct->price;
$order->quantity = $orderItem['quantity'];
$order->save();

}

DB::commit();

} catch (\Exception $e) {

DB::rollBack();
throw $e;

}

return response()->json(['result' => true]);
}

}

routes/api.php

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\ProductController;
use App\Http\Controllers\Api\OrderController;

Route::prefix('product')->group(function () {
// 料理データを取得
Route::get('/', [ProductController::class, 'index']);
});

// 注文
Route::prefix('order')->group(function () {
// 注文作成
Route::post('/', [OrderController::class, 'store']);
});

7. NativePHPをビルドする

今回はVue 3 + Inertiaで構成しているのでビルドが必要です。
以下コマンドを実行してください。

npm run build

これで作業は完了です!
お疲れ様でした😊✨

テストしてみる

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

以下のコマンドでNativePHPを起動します。

php artisan native:run

どうなるでしょうか・・・・・・

操作部分は動画をご覧ください!

はい!
うまく注文できたようです。

では、データベースの方もチェックしておきましょう。

はい!
こちらもきちんとデータ登録されています。

すべて成功です✨😊👍

企業様へのご提案

今回のように「ライトなアプリ」であればLaravelで開発することもできます。
メリットは以下5つ。

  • 学習コストが低い:これまでのLaravel知識をそのまま使える!
  • 開発スピードが早い:ウェブベースで開発し、ビルドするだけなのでいちいちビルドして確認する必要がない
  • コードを一元管理できる:AndroidやiOSだけでなくデスクトップやウェブにもコードがつかえるので、一元管理しやすい
  • ウェブの技術を併用できる:ReactやVueなど、他の技術やパッケージもつかえる。
  • 情報が豊富:ベースはLaravelなので、本体の機能は情報が豊富です。

逆にデメリットは3つです。

  • 制約がある:WebViewを使うので、例えば、カメラのリアルタイム再生はできない。
  • 情報の不足:Laravelは情報が豊富ですが、NativePHP自体は新しい技術なので、情報が少ない傾向にある
  • ライセンス料:2025/10/27現在、ライセンス料が必要。詳しくは本家ページをご覧ください。

おわりに

ということで、今回はNativePHPでセルフオーダー機能をつくってみました!

使ってみた感想としては「個人的なアプリ、もしくは、すでに運営してるサイトのアプリ化ならいいかな」ぐらいのカンジでした。

というのも、当初の記事はイオンモールにある「レジゴー(商品をとりながらピッピやるやつです)」をつくりたかったんです。

でも、なんとWebViewを使っているので、カメラをリアルタイムで表示&読み取りができない(つまり、QRコードが読み込めない)ということで、今後も「あとからできない可能性」が明るみになるかもしれず、新プロジェクトに使うにはちょっと怖くもありました。

なので、現状ではLivewireのように「おっ、これいいじゃん → うーん、結局は王道の開発がベターかな…」となりそうな気もしています。

ただ、いろいろなやり方があってもいいとは思いますし、今後に期待したいですね!

ぜひ皆さんも試してみてください。

ではでは〜!

「カナダの公園で寝っ転がったら、
ウ●コの上だった、って話は鉄板です😂」

このエントリーをはてなブックマークに追加       follow us in feedly  
お問い合わせ、お待ちしております。
開発のご依頼はこちら: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ