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 page
For 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 generate
Generated 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 install
If 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-loader
grpc-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.js
If 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 loaded
Enable the extension in the correct php.ini:
extension=grpc.so
5️⃣ 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.js
Terminal 2 — Laravel + Vite
composer install
cp .env.example .env
php artisan key:generate
npm install
npm run dev
php artisan serve
When 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.