Add a feedback widget to a Laravel app
Add the widget script to the head of your Blade layout with defer. Render the signed-in user into a meta tag with json_encode, which Blade escapes, then call identify from a Vite module. Set the Content Security Policy in middleware, allowing only the widget host next to your own origin.
By Rafael Thayto · Last updated
Add the widget to the Blade layout
Laravel views usually extend one layout, such as resources/views/layouts/app.blade.php. Put the widget script in its head with defer. Every view that extends the layout then gets the widget, and the browser loads it once per full page load:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('app.name') }}</title>
<script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" defer></script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
@auth
<meta name="escuta-user" content="{{ json_encode([
'id' => (string) auth()->id(),
'email' => auth()->user()->email,
'name' => auth()->user()->name,
]) }}">
@endauth
@yield('content')
@stack('scripts')
</body>
</html>
The data-key is your public product key, which can only create feedback. The @vite directive loads your own bundle, and that bundle is where the identify code lives. Keep the widget script ahead of it in the head, so the queue exists before your module runs.
The @auth block renders the meta tag only for a signed-in user. For visitors who are signed out, the tag is absent and the identify code does nothing.
Escape the user data inside the meta tag
Blade's {{ }} syntax escapes its output with htmlspecialchars, so json_encode produces JSON whose quotes become " inside the attribute. The browser decodes the entities when it reads the attribute, which gives back the original JSON text. A name with quotes, angle brackets or ampersands can never end the attribute or start a tag.
Build the array with only the fields the widget needs. Cast the id to a string, because the widget expects text. Do not use {!! !!} for this output, because that skips escaping and would let a crafted name break the markup.
If your user model keeps the display name in another column, change the key accordingly. The identify call should send the same fields you would show the person, since the browser can read them with developer tools.
Identify the user from a Vite module
The identify code lives in a module that Vite bundles with your app. It reads the meta tag and pushes the identify call onto the widget queue:
// resources/js/escuta-identify.js
const el = document.querySelector('meta[name="escuta-user"]');
if (el) {
const user = JSON.parse(el.content);
window.EscutaProduto = window.EscutaProduto || { q: [] };
(window.EscutaProduto.q ||= []).push(["identify", [user]]);
}
Import it from your main entry file, so it runs once on each full page load:
// resources/js/app.js
import "./escuta-identify.js";
A module file avoids inline scripts entirely. The browser runs it from your own origin, which keeps the Content Security Policy simple. Modules are deferred by default, so the widget script in the head has already queued its code by the time this runs.
If you prefer an inline script, Blade's @json directive works inside a <script> block when you pass the HEX flags explicitly. Those flags encode angle brackets, ampersands and quotes, so the value cannot close the script tag:
<script>
window.EscutaProduto = window.EscutaProduto || { q: [] };
(window.EscutaProduto.q ||= []).push(["identify", [@json($escutaUser, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)]]);
</script>
That inline version needs a nonce once you enforce a strict policy, so the module approach is simpler when you have one.
Set the Content Security Policy in middleware
Laravel does not set a Content Security Policy by default. Add one with a small middleware that writes the header on each response:
// app/Http/Middleware/ContentSecurityPolicy.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' https://escutaproduto.com; connect-src 'self' https://escutaproduto.com"
);
return $response;
}
}
Register it on the web group in bootstrap/app.php, which is where Laravel 11 and later configure middleware:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
\App\Http\Middleware\ContentSecurityPolicy::class,
]);
})
Vite's dev server serves code from a different origin and injects its own inline helpers, so a strict header can break npm run dev. Apply the middleware only in production, or add the Vite dev origin and its sources in development. The CSP guide for widgets explains how to find a blocked request in the browser console.
Livewire and Inertia navigation
Livewire's wire:navigate and Inertia both swap the page content without a full reload, which is similar to Turbo. The widget script in the head stays loaded, so the Feedback button keeps working. For Inertia, read the user from the shared page props in a component and call identify in an effect, using the same pattern as the React guide. For Livewire, the meta tag and the module run once per full load, so a sign-in that happens through navigation needs a full page load to refresh the identity.
Send feedback from a Laravel controller
When feedback starts on the server, for example from a support form, send it with the HTTP client. Validate the message first, then post it to the REST API:
// app/Http/Controllers/FeedbackController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
public function store(Request $request)
{
$data = $request->validate([
'message' => 'required|string|min:2|max:4000',
]);
$response = Http::timeout(5)
->acceptJson()
->post('https://escutaproduto.com/api/v1/feedback', [
'key' => 'pk_your_product_key',
'kind' => 'other',
'message' => $data['message'],
'email' => $request->user()?->email,
]);
if ($response->status() === 429) {
return back()->with('status', 'Too many messages right now. Please try again in a minute.');
}
return back()->with('status', 'Thanks, we received your message.');
}
The API returns 201 with an id on success, 400 for invalid data and 413 for a body over 16 KB. It also limits each IP to 10 requests a minute per product with 429, so the controller shows a retry message in that case. Server requests carry no Origin header, so the allowed origins list does not apply to them.
Open the form from a Blade view
A button with data-escuta-open opens the form, with the type preselected:
<button type="button" data-escuta-open="praise">Tell us what you like</button>
Valid types are bug, idea, praise and other. To hide the floating button, add data-trigger="none" to the script tag in the layout. The attribute works in any Blade view that uses the layout.
Check the Laravel install in your Escuta Produto inbox
- Add
http://localhost:8000(the defaultphp artisan serveURL) to the allowed origins of your product. - Sign in, open the Feedback button and send a test bug from a page that uses the layout.
- In the inbox, check that the item shows your name and email, plus the page URL.
- Sign out, reload and send another item. That item should have no name, which confirms the signed-out case.
If the name is missing, view the page source and look for the escuta-user meta tag. If it is absent, the @auth block did not render, so check that the layout is the one the page actually uses.
For every option, read the widget reference. The Rails guide covers the same layout pattern in Ruby, and the Django guide covers it in Python.
Frequently asked questions
Where do I add the feedback widget script in a Laravel app?
In the head of your main Blade layout, such as resources/views/layouts/app.blade.php, with the defer attribute. Every view that extends the layout gets the script, and the Feedback button shows on each page.
How do I pass the logged-in user to the widget in Laravel safely?
Render a meta tag whose content is json_encode of the id, email and name. Blade escapes the output for the attribute, and a Vite module parses it with JSON.parse before calling identify, so no value can break out of the markup.
Can I use the Laravel @json directive inside a script block instead?
Yes, if you pass the HEX flags explicitly, such as JSON_HEX_TAG and JSON_HEX_AMP. Those flags encode angle brackets and ampersands, so the value cannot close the script tag. The meta tag approach avoids inline code altogether.
How do I set a Content Security Policy for the widget in Laravel?
Add a middleware that sets the Content-Security-Policy header, and include the widget host in script-src and connect-src. Apply it to the web group in bootstrap/app.php. Skip it in local development, where Vite needs its own sources.
Related
- Add a feedback widget to a React appInstall the Escuta Produto feedback widget in a Vite React app: the script in index.html, identify from your auth context, and how routes behave.
- Add a feedback widget to a Vue appAdd the Escuta Produto feedback widget to a Vue 3 app with Vite: script in index.html, identify in a composable after login, and Vue Router notes.
- Add a feedback widget to a Nuxt appAdd the Escuta Produto feedback widget to a Nuxt app: app.head script config, a client-only plugin for identify, and server rendering caveats.
- Add a feedback widget to a SvelteKit appAdd the Escuta Produto feedback widget to a SvelteKit app: the app.html script, identify from layout data, and Content Security Policy in svelte.config.js.