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 migrate2. 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/1PHPUnit 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-keyDocker
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.