Laravel + Vue (Inertia) + gRPC: building a simple BFF that talks to a gRPC User service
Source: Dev.to
Why gRPC in a Laravel + Vue project?
If you’re building a modern Laravel + Vue app, your default instinct is usually REST/JSON.
gRPC is different: you define your API as a contract in a .proto file, then generate strongly‑typed client/server code (stubs). It runs over HTTP/2 and uses Protocol Buffers for compact serialization.
Note: A Vue app in the browser can’t talk gRPC directly without extra infrastructure (gRPC‑Web + a proxy such as Envoy).
A practical pattern is:✅ Vue (browser) → Laravel (HTTP/JSON) → gRPC microservice (internal)
Laravel becomes your BFF (Backend‑For‑Frontend). This repository demonstrates exactly that.
Goal
Display a user on a Vue page, but fetch it through Laravel, which calls a gRPC server.
Flow
- Vue page (
UserShow.vue) callsGET /api/users/{id}. - Laravel API route invokes
App\Services\UserGrpc. UserGrpcuses the generated gRPC client to callUserService.GetUser.- A Node gRPC server returns user data (demo reads the same SQLite DB Laravel uses).
Folder layout (highlights)
proto/user/v1/user.proto → contract
generated/App/Grpc/... → generated PHP stubs (client classes)
grpc-server/ → demo gRPC server (Node)
app/Services/UserGrpc.php → Laravel gRPC client wrapper
resources/js/Pages/UserShow.vue → Vue / Inertia pageFor a fresh project, Laravel’s Vue starter kit (Inertia) is a great base.
1️⃣ Define the contract (proto/user/v1/user.proto)
syntax = "proto3";
package user.v1;
service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
string id = 1;
string name = 2;
}The .proto file is the single source of truth.
2️⃣ Generate PHP client stubs
buf standardises Protobuf workflows and generation config (buf.yaml, buf.gen.yaml).
npx buf generateGenerated files live under generated/App/Grpc/....
Tip: Many teams do not commit generated code (they generate it in CI).
Committing it is fine for demos and fast setup.
Important: Generating PHP client stubs does not create a server. You still need a gRPC server implementation (Go, Node, Java, PHP, …). In this repo the demo server is under
grpc-server/.
3️⃣ Demo gRPC server (Node)
cd grpc-server
npm installIf you see errors like Cannot find module 'dotenv' or Cannot find module 'better-sqlite3', install the missing packages:
npm install dotenv better-sqlite3 @grpc/grpc-js @grpc/proto-loadergrpc-server/server.js
require("dotenv").config();
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const PROTO_PATH = __dirname + "/../proto/user/v1/user.proto";
const packageDef = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const userProto = grpc.loadPackageDefinition(packageDef).user.v1;
// Demo handler
function GetUser(call, callback) {
const { id } = call.request;
// Minimal demo response
callback(null, { id, name: `User #${id}` });
}
function main() {
const server = new grpc.Server();
server.addService(userProto.UserService.service, { GetUser });
const addr = process.env.GRPC_ADDR || "0.0.0.0:50051";
server.bindAsync(
addr,
grpc.ServerCredentials.createInsecure(),
() => {
console.log(`gRPC server listening on ${addr}`);
server.start();
}
);
}
main();Start the server:
node server.jsIf Laravel reports Connection refused, the gRPC server isn’t running or isn’t listening on the expected address/port.
4️⃣ Laravel gRPC client wrapper (app/Services/UserGrpc.php)
client = new UserServiceClient(
env('USER_SVC_ADDR', '127.0.0.1:50051'),
['credentials' => ChannelCredentials::createInsecure()]
);
}
public function getUser(string $id): array
{
$req = new GetUserRequest();
$req->setId($id);
/** @var \App\Grpc\User\V1\GetUserResponse $resp */
[$resp, $status] = $this->client->GetUser($req)->wait();
if ($status->code !== STATUS_OK) {
throw new \RuntimeException($status->details, $status->code);
}
return [
'id' => $resp->getId(),
'name' => $resp->getName(),
];
}
}Common issue: Grpc\ChannelCredentials not found
That usually means the gRPC PHP extension isn’t enabled for the PHP you’re running (CLI vs FPM can differ).
php -m | grep grpc # should list "grpc"
php --ini # shows which php.ini is loadedEnable the extension in the correct php.ini:
extension=grpc.so5️⃣ Expose a classic JSON API endpoint
routes/api.php
use Illuminate\Support\Facades\Route;
use App\Services\UserGrpc;
Route::get('/users/{id}', function (string $id, UserGrpc $userGrpc) {
return response()->json($userGrpc->getUser($id));
});routes/web.php (Inertia page route)
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/users/{id}', function (string $id) {
return Inertia::render('UserShow', [
'id' => $id,
]);
});6️⃣ Vue page (resources/js/Pages/UserShow.vue)
A clean Inertia‑friendly version uses props (no Vue Router required).
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
type User = { id: string; name: string };
const props = defineProps<{ id: string }>();
const id = computed(() => props.id);
const loading = ref(true);
const error = ref<string | null>(null);
const user = ref<User | null>(null);
onMounted(async () => {
try {
const resp = await fetch(`/api/users/${id.value}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
user.value = await resp.json();
} catch (e: any) {
error.value = e.message;
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h2>User {{ id }}</h2>
<div v-if="loading">Loading…</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>
<p><strong>ID:</strong> {{ user?.id }}</p>
<p><strong>Name:</strong> {{ user?.name }}</p>
</div>
</div>
</template>Recap
| Step | What you do |
|---|---|
| 1 | Write a .proto contract. |
| 2 | Generate PHP client stubs (buf generate). |
| 3 | Run a gRPC server (Node demo). |
| 4 | Wrap the generated client in a Laravel service (UserGrpc). |
| 5 | Expose a JSON API endpoint that calls the service. |
| 6 | Inertia/Vue page fetches the JSON endpoint. |
With this setup you get:
- Strong typing across services (thanks to Protobuf).
- Efficient binary transport inside your backend (HTTP/2 + protobuf).
- A clean BFF that shields the browser from gRPC‑Web complexities.
Happy coding! 🚀
Additional Vue example (alternative implementation)
<script setup lang="ts">
import { ref, onMounted } from "vue";
const id = ref(/* route param or similar */);
const user = ref<{ id: string; name: string }>({ id: "", name: "" });
const loading = ref(true);
const error = ref<string | null>(null);
onMounted(async () => {
try {
const res = await fetch(`/api/users/${encodeURIComponent(id.value)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
user.value = await res.json();
} catch (e: any) {
error.value = e?.message ?? "Unknown error";
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h2>User</h2>
<div v-if="loading">Loading…</div>
<div v-else-if="error">{{ error }}</div>
<div v-else>
<p><strong>ID:</strong> {{ user.id }}</p>
<p><strong>Name:</strong> {{ user.name }}</p>
</div>
</div>
</template>Running the services
Terminal 1 — gRPC server
cd grpc-server
node server.jsTerminal 2 — Laravel + Vite
composer install
cp .env.example .env
php artisan key:generate
npm install
npm run dev
php artisan serveWhen to use this setup
- You want typed contracts between internal services.
- You have multiple backends in different languages.
- You need a stable interface (proto) shared across teams.
- You want performance and streaming features (gRPC supports streaming).
If your app is a small monolith and the public‑facing API is your only concern, REST might still be simpler.