โ† Back to Connections

Connect Laravel to PostPenguin

Medium SetupLanguage: PHPTime: 15 minutes

Laravel provides an elegant way to receive PostPenguin webhooks. This guide shows you how to create a webhook endpoint with proper validation and database storage.

๐Ÿš€ Quick Setup

1. Create Migration

php artisan make:migration create_posts_table
<?php
// database/migrations/xxxx_create_posts_table.php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('postpenguin_id')->unique();
            $table->string('title');
            $table->string('slug')->unique();
            $table->longText('html');
            $table->string('meta_title')->nullable();
            $table->text('meta_description')->nullable();
            $table->string('featured_image', 1000)->nullable();
            $table->json('tags')->default('[]');
            $table->string('status')->default('publish');
            $table->timestamp('published_at')->nullable();
            $table->timestamps();
            
            $table->index('status');
            $table->index('published_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};
php artisan migrate

2. Create Post Model

php artisan make:model Post
<?php
// app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Post extends Model
{
    use HasFactory;

    protected $fillable = [
        'postpenguin_id',
        'title',
        'slug',
        'html',
        'meta_title',
        'meta_description',
        'featured_image',
        'tags',
        'status',
        'published_at',
    ];

    protected $casts = [
        'tags' => 'array',
        'published_at' => 'datetime',
    ];

    public function scopePublished($query)
    {
        return $query->where('status', 'publish');
    }

    public function scopeRecent($query)
    {
        return $query->orderBy('published_at', 'desc');
    }
}

3. Create Webhook Controller

php artisan make:controller Webhooks/PostPenguinController
<?php
// app/Http/Controllers/Webhooks/PostPenguinController.php

namespace App\Http\Controllers\Webhooks;

use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class PostPenguinController extends Controller
{
    public function handle(Request $request)
    {
        // Verify signature
        if (!$this->verifySignature($request)) {
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        // Validate request
        $validated = $request->validate([
            'postPenguinId' => 'nullable|string',
            'title' => 'required|string|max:500',
            'slug' => 'required|string|max:255',
            'html' => 'required|string',
            'meta_title' => 'nullable|string|max:500',
            'meta_description' => 'nullable|string',
            'featured_image' => 'nullable|url|max:1000',
            'tags' => 'nullable|array',
            'tags.*' => 'string',
        ]);

        try {
            $postpenguinId = $validated['postPenguinId'] ?? 'pp_' . Str::random(12);

            // Find or create post
            $post = Post::updateOrCreate(
                ['postpenguin_id' => $postpenguinId],
                [
                    'title' => $validated['title'],
                    'slug' => $validated['slug'],
                    'html' => $validated['html'],
                    'meta_title' => $validated['meta_title'] ?? $validated['title'],
                    'meta_description' => $validated['meta_description'] ?? '',
                    'featured_image' => $validated['featured_image'] ?? null,
                    'tags' => $validated['tags'] ?? [],
                    'status' => 'publish',
                    'published_at' => now(),
                ]
            );

            $action = $post->wasRecentlyCreated ? 'created' : 'updated';

            Log::info("PostPenguin post {$action}: {$post->title}");

            return response()->json([
                'success' => true,
                'postId' => $post->id,
                'postPenguinId' => $post->postpenguin_id,
                'action' => $action,
            ]);

        } catch (\Exception $e) {
            Log::error('PostPenguin webhook error: ' . $e->getMessage());
            return response()->json(['error' => 'Internal server error'], 500);
        }
    }

    private function verifySignature(Request $request): bool
    {
        $secret = config('services.postpenguin.webhook_secret');
        
        if (empty($secret)) {
            return true; // Skip verification if no secret configured
        }

        $signature = $request->header('X-PostPenguin-Signature');
        
        if (empty($signature)) {
            return false;
        }

        $payload = $request->getContent();
        $expected = hash_hmac('sha256', $payload, $secret);
        $received = str_replace('sha256=', '', $signature);

        return hash_equals($expected, $received);
    }
}

4. Create API Controller

<?php
// app/Http/Controllers/Api/PostController.php

namespace App\Http\Controllers\Api;

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

class PostController extends Controller
{
    public function index(Request $request)
    {
        $limit = $request->input('limit', 10);
        $offset = $request->input('offset', 0);

        $query = Post::published()->recent();
        $total = $query->count();
        $posts = $query->skip($offset)->take($limit)->get();

        return response()->json([
            'posts' => $posts,
            'pagination' => [
                'total' => $total,
                'limit' => (int) $limit,
                'offset' => (int) $offset,
            ],
        ]);
    }

    public function show(Post $post)
    {
        return response()->json(['post' => $post]);
    }

    public function bySlug(string $slug)
    {
        $post = Post::published()->where('slug', $slug)->firstOrFail();
        return response()->json(['post' => $post]);
    }
}

5. Add Routes

<?php
// routes/api.php

use App\Http\Controllers\Webhooks\PostPenguinController;
use App\Http\Controllers\Api\PostController;

// Webhook route (exclude from CSRF)
Route::post('/webhooks/postpenguin', [PostPenguinController::class, 'handle'])
    ->withoutMiddleware(['throttle:api']);

// Posts API
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::get('/posts/slug/{slug}', [PostController::class, 'bySlug']);

6. Exclude from CSRF

<?php
// app/Http/Middleware/VerifyCsrfToken.php

protected $except = [
    'api/webhooks/postpenguin',
];

โš™๏ธ Configuration

# .env
POSTPENGUIN_WEBHOOK_SECRET=your-secret-key-here
<?php
// config/services.php

return [
    // ... other services
    
    'postpenguin' => [
        'webhook_secret' => env('POSTPENGUIN_WEBHOOK_SECRET'),
    ],
];

๐Ÿงช Testing

Test Webhook

curl -X POST http://localhost:8000/api/webhooks/postpenguin \
  -H "Content-Type: application/json" \
  -d '{
    "postPenguinId": "test_laravel_123",
    "title": "Test Laravel Post",
    "slug": "test-laravel-post",
    "html": "<p className="text-gray-700">This is a test post using Laravel.</p>",
    "meta_title": "Test Post",
    "meta_description": "Testing Laravel webhook",
    "tags": ["test", "laravel", "php"]
  }'

Test Posts API

# Get all posts
curl http://localhost:8000/api/posts

# Get single post by slug
curl http://localhost:8000/api/posts/slug/test-laravel-post

# Get single post by ID
curl http://localhost:8000/api/posts/1

PHPUnit Tests

<?php
// tests/Feature/PostPenguinWebhookTest.php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;

class PostPenguinWebhookTest extends TestCase
{
    use RefreshDatabase;

    public function test_webhook_creates_post()
    {
        $response = $this->postJson('/api/webhooks/postpenguin', [
            'postPenguinId' => 'test_123',
            'title' => 'Test Post',
            'slug' => 'test-post',
            'html' => '<p className="text-gray-700">Test content</p>',
            'tags' => ['test'],
        ]);

        $response->assertStatus(200)
                 ->assertJson(['success' => true, 'action' => 'created']);

        $this->assertDatabaseHas('posts', [
            'postpenguin_id' => 'test_123',
            'title' => 'Test Post',
        ]);
    }

    public function test_webhook_updates_existing_post()
    {
        Post::create([
            'postpenguin_id' => 'test_123',
            'title' => 'Old Title',
            'slug' => 'old-slug',
            'html' => '<p className="text-gray-700">Old content</p>',
        ]);

        $response = $this->postJson('/api/webhooks/postpenguin', [
            'postPenguinId' => 'test_123',
            'title' => 'New Title',
            'slug' => 'new-slug',
            'html' => '<p className="text-gray-700">New content</p>',
        ]);

        $response->assertStatus(200)
                 ->assertJson(['success' => true, 'action' => 'updated']);

        $this->assertDatabaseHas('posts', [
            'postpenguin_id' => 'test_123',
            'title' => 'New Title',
        ]);
    }
}

๐Ÿš€ Production Deployment

Laravel Forge / Vapor

Add the environment variable in your deployment settings:

POSTPENGUIN_WEBHOOK_SECRET=your-secret-key

Docker

FROM php:8.2-fpm

WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader

EXPOSE 9000
CMD ["php-fpm"]

Need Help?

Check our webhook documentation for technical details, or contact support for custom integrations.