Shamim Shams Shamim Shams

Implement Sentiment Analysis on User Reviews in Laravel

· 7 min read
Implement Sentiment Analysis on User Reviews in Laravel

A 4.1-star average tells you almost nothing. Two hundred four-star reviews can mean "genuinely excellent product" or "persistent problems that one good feature nearly canceled out." The number collapses the signal you actually need — whether customers are frustrated about shipping speed, satisfied with build quality, or writing reviews that sound positive while meaning something else entirely.

This tutorial adds AI-powered sentiment analysis to a Laravel application. Every new review gets labeled positive, neutral, or negative, with a confidence score and a one-sentence reason pulled from the text itself. The analysis runs asynchronously so it doesn't touch your request cycle, stores directly on the review record, and surfaces in a filterable admin view.

We're using OpenAI's gpt-4o-mini via openai-php/laravel. At roughly $0.15 per million input tokens, analyzing every review is a non-issue even at a few thousand submissions per day.

The Database Schema

Four columns on the reviews table handle everything:

php artisan make:migration add_sentiment_to_reviews_table
public function up(): void
{
    Schema::table('reviews', function (Blueprint $table) {
        $table->string('sentiment')->nullable();          // positive | neutral | negative
        $table->decimal('sentiment_confidence', 3, 2)->nullable();
        $table->string('sentiment_reason', 300)->nullable();
        $table->timestamp('sentiment_analyzed_at')->nullable();
    });
}
php artisan migrate

sentiment_analyzed_at earns its place when your prompt changes. You can queue a reprocess job for reviews analyzed before a specific date without touching recent ones — which matters more than it sounds once you've tuned the classifier a few times.

Add OpenAI to Laravel

composer require openai-php/laravel
php artisan vendor:publish --provider="OpenAI\Laravel\ServiceProvider"

Add to .env:

OPENAI_API_KEY=your-key-here

The Sentiment Service

One class, one responsibility: take review text, return structured sentiment data.

<?php

namespace App\Services;

use OpenAI\Laravel\Facades\OpenAI;
use Illuminate\Support\Facades\Log;

class SentimentAnalysisService
{
    public function analyze(string $reviewText): array
    {
        try {
            $response = OpenAI::chat()->create([
                'model'           => 'gpt-4o-mini',
                'temperature'     => 0.1,
                'max_tokens'      => 200,
                'messages'        => [
                    ['role' => 'system', 'content' => $this->systemPrompt()],
                    ['role' => 'user', 'content' => $reviewText],
                ],
                'response_format' => ['type' => 'json_object'],
            ]);

            $data = json_decode($response->choices[0]->message->content, true);

            return [
                'sentiment'  => $data['sentiment'] ?? 'neutral',
                'confidence' => (float) ($data['confidence'] ?? 0.5),
                'reason'     => $data['reason'] ?? '',
            ];
        } catch (\Exception $e) {
            Log::warning('Sentiment analysis failed', [
                'review_length' => strlen($reviewText),
                'error'         => $e->getMessage(),
            ]);

            return ['sentiment' => null, 'confidence' => null, 'reason' => null];
        }
    }

    private function systemPrompt(): string
    {
        return <<<'PROMPT'
You classify product and service reviews. Respond with valid JSON only. No explanation.

Schema:
{
  "sentiment": "positive" | "neutral" | "negative",
  "confidence": number between 0.0 and 1.0,
  "reason": "One sentence quoting or paraphrasing the phrase that drove your classification"
}

Rules:
- positive: satisfied, recommending, or expressing appreciation
- negative: dissatisfied, frustrated, or warning others
- neutral: mixed or genuinely ambiguous — not a fallback for uncertainty
- confidence: 0.90+ for clear cases, 0.55–0.70 for genuinely ambiguous ones
- reason: quote a specific phrase from the review when possible
PROMPT;
    }
}

temperature: 0.1 keeps classification consistent — the same review text produces the same label on repeated calls. response_format: json_object prevents the model from wrapping JSON in a markdown fence, which causes json_decode to return null without raising any error.

On failure, I return null values rather than a fake neutral. An unlabeled review is recoverable. A mislabeled one quietly corrupts your reporting for months.

Attach It to the Review Model

Create a queued job for processing:

php artisan make:job AnalyzeReviewSentiment
<?php

namespace App\Jobs;

use App\Models\Review;
use App\Services\SentimentAnalysisService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class AnalyzeReviewSentiment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries   = 3;
    public int $backoff = 30;

    public function __construct(private readonly Review $review) {}

    public function handle(SentimentAnalysisService $service): void
    {
        $result = $service->analyze($this->review->body);

        $this->review->update([
            'sentiment'             => $result['sentiment'],
            'sentiment_confidence'  => $result['confidence'],
            'sentiment_reason'      => $result['reason'],
            'sentiment_analyzed_at' => now(),
        ]);
    }
}

Wire the dispatch to an observer so controllers don't need to know about sentiment at all:

php artisan make:observer ReviewObserver --model=Review
// app/Observers/ReviewObserver.php

public function created(Review $review): void
{
    AnalyzeReviewSentiment::dispatch($review)->delay(now()->addSeconds(5));
}

Register it in AppServiceProvider::boot():

Review::observe(ReviewObserver::class);

The five-second delay sidesteps a race condition where the job reads the record before the transaction commits. I've been caught by this with SQLite in test environments — it surfaces as a job that silently processes a null body and stores a neutral classification that was never real.

Can You Trust the Label?

For clear reviews, yes. Confidence at 0.90 or above is reliable in practice.

The tricky cases are predictable enough that you can route around them. A five-word review like "Fine." is genuinely ambiguous — satisfied? resigned? barely-passed? The model will output a label, but confidence drops to around 0.60 and the reason becomes vague or circular. Low-confidence output shouldn't drive automated responses or surface in dashboards as signal.

Sarcasm is genuinely fiddly. "Oh fantastic, arrived broken on day one" — depending on how deadpan the phrasing reads, the model may classify this positive. I've seen gpt-4o-mini catch obvious sarcasm reliably and miss subtle understatement entirely. The practical fix isn't prompt engineering; it's a confidence threshold. Anything below 0.75 goes into a human review queue rather than flowing directly into reporting.

Your negative filter will catch more than you expect when mixed reviews come through. "Delivery was fast but product quality is a let-down" goes negative because dissatisfaction is the stronger signal — and that's usually the right call. But if you care about which aspects customers mention separately — shipping versus quality versus packaging — sentiment labels don't give you that. You'd need aspect extraction on top of classification, which is a separate problem.

The Admin Filter

Add a scope to your Review model:

public function scopeWithSentiment(Builder $query, string $sentiment): Builder
{
    return $query->where('sentiment', $sentiment)
                 ->whereNotNull('sentiment_analyzed_at');
}

The controller:

public function index(Request $request): View
{
    $sentiment = $request->query('sentiment');

    $reviews = Review::query()
        ->when($sentiment, fn($q) => $q->withSentiment($sentiment))
        ->latest()
        ->paginate(25);

    return view('admin.reviews.index', compact('reviews', 'sentiment'));
}

Blade output per row:

<td>
    @if($review->sentiment)
        <span class="badge badge--{{ $review->sentiment }}">
            {{ ucfirst($review->sentiment) }}
            ({{ number_format($review->sentiment_confidence * 100) }}%)
        </span>
        <p class="text-sm text-muted">{{ $review->sentiment_reason }}</p>
    @else
        <span class="badge badge--pending">Pending</span>
    @endif
</td>

Before This Goes Live

Backfill existing reviews before enabling the filter. An empty "Negative" list on first load is misleading — it reads like everything is fine, not like analysis hasn't run yet:

// php artisan tinker
Review::whereNull('sentiment_analyzed_at')->chunkById(100, function ($chunk) {
    $chunk->each(fn($r) => AnalyzeReviewSentiment::dispatch($r));
    sleep(2);
});

The sleep(2) between chunks keeps you under gpt-4o-mini's 500-request-per-minute default rate limit. Adjust based on your chunk size and how aggressively your queue workers consume jobs.

Don't expose sentiment labels to the customer who wrote the review. "We've classified your feedback as negative" is a bad user experience and teaches reviewers to write more positive-sounding text rather than honest feedback. This is internal signal, not a transparency feature.

Your system prompt needs tuning for your product category. The rules above generalize well for standard e-commerce reviews. SaaS product feedback with technical vocabulary, restaurant reviews where "service" and "food" are separate dimensions, or anything involving regulated language will have patterns the generic prompt misses. Run 100 manual spot-checks before using this output for any reporting or automated action — the threshold you trust in development will feel very different once real customer language comes through.