Laravel Integration
Complete example of MONCRENEAU API integration with Laravel 10.x.
Project Structure
moncreneau-laravel/
├── composer.json
├── .env.example
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── AppointmentController.php
│ │ │ └── WebhookController.php
│ │ └── Middleware/
│ │ └── VerifyWebhookSignature.php
│ └── Services/
│ ├── MonCreneauService.php
│ └── WebhookEventService.php
├── config/
│ └── moncreneau.php
├── routes/
│ ├── api.php
│ └── web.php
└── tests/
├── Feature/
│ ├── AppointmentTest.php
│ └── WebhookTest.php
└── Unit/
└── WebhookSignatureTest.php
Configuration
composer.json
{
"name": "your-org/moncreneau-laravel",
"type": "project",
"description": "MONCRENEAU API integration with Laravel",
"require": {
"php": "^8.1",
"laravel/framework": "^10.0",
"guzzlehttp/guzzle": "^7.8"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"mockery/mockery": "^1.6"
},
"autoload": {
"psr-4": {
"App\": "app/"
}
},
"scripts": {
"test": "phpunit"
}
}
.env.example
# MONCRENEAU API
MONCRENEAU_API_URL=https://mc-prd.duckdns.org/api/v1
MONCRENEAU_API_KEY=your_api_key_here
MONCRENEAU_WEBHOOK_SECRET=your_webhook_secret_here
# Application
APP_NAME=MonCreneauIntegration
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost
config/moncreneau.php
<?php
return [
'api_url' => env('MONCRENEAU_API_URL', 'https://mc-prd.duckdns.org/api/v1'),
'api_key' => env('MONCRENEAU_API_KEY'),
'webhook_secret' => env('MONCRENEAU_WEBHOOK_SECRET'),
'timeout' => 30, // seconds
'retry_attempts' => 3,
];
Service Layer
app/Services/MonCreneauService.php
<?php
namespace AppServices;
use GuzzleHttpClient;
use GuzzleHttpExceptionRequestException;
use IlluminateSupportFacadesLog;
use Exception;
class MonCreneauService
{
private Client $client;
private string $apiUrl;
private string $apiKey;
public function __construct()
{
$this->apiUrl = config('moncreneau.api_url');
$this->apiKey = config('moncreneau.api_key');
$this->client = new Client([
'base_uri' => $this->apiUrl,
'timeout' => config('moncreneau.timeout'),
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]);
}
public function createAppointment(array $data): array
{
try {
Log::info('Creating appointment', [
'organizationId' => $data['organizationId'] ?? null,
]);
$response = $this->client->post('/appointments', [
'json' => $data,
]);
$appointment = json_decode($response->getBody(), true);
Log::info('Appointment created', [
'appointmentId' => $appointment['id'],
'status' => $appointment['status'],
]);
return $appointment;
} catch (RequestException $e) {
return $this->handleApiException($e);
}
}
public function getAppointment(int $appointmentId): array
{
try {
$response = $this->client->get("/appointments/{$appointmentId}");
return json_decode($response->getBody(), true);
} catch (RequestException $e) {
return $this->handleApiException($e);
}
}
public function cancelAppointment(int $appointmentId): void
{
try {
Log::info("Cancelling appointment {$appointmentId}");
$this->client->delete("/appointments/{$appointmentId}");
Log::info("Appointment {$appointmentId} cancelled");
} catch (RequestException $e) {
$this->handleApiException($e);
}
}
private function handleApiException(RequestException $e): void
{
$statusCode = $e->getResponse()?->getStatusCode();
$body = $e->getResponse()?->getBody()->getContents();
Log::error('MONCRENEAU API error', [
'status' => $statusCode,
'body' => $body,
'message' => $e->getMessage(),
]);
if ($statusCode === 402) {
throw new Exception('Insufficient credits');
} elseif ($statusCode === 400) {
$errors = json_decode($body, true);
throw new Exception('Invalid data: ' . json_encode($errors));
} elseif ($statusCode === 429) {
throw new Exception('Rate limit exceeded');
}
throw new Exception('API error: ' . $e->getMessage());
}
}
app/Services/WebhookEventService.php
<?php
namespace AppServices;
use IlluminateSupportFacadesLog;
class WebhookEventService
{
public function processEvent(string $eventType, array $eventData): void
{
Log::info('Processing webhook event', [
'type' => $eventType,
'appointmentId' => $eventData['object']['id'] ?? null,
]);
match ($eventType) {
'appointment.created' => $this->handleAppointmentCreated($eventData),
'appointment.updated' => $this->handleAppointmentUpdated($eventData),
'appointment.cancelled' => $this->handleAppointmentCancelled($eventData),
'appointment.validated' => $this->handleAppointmentValidated($eventData),
'appointment.completed' => $this->handleAppointmentCompleted($eventData),
default => Log::warning('Unknown event type', ['type' => $eventType]),
};
}
private function handleAppointmentCreated(array $eventData): void
{
Log::info('Handling appointment.created');
// Send confirmation email
// Send SMS
// Update local database
}
private function handleAppointmentUpdated(array $eventData): void
{
Log::info('Handling appointment.updated');
// Notify user of changes
}
private function handleAppointmentCancelled(array $eventData): void
{
Log::info('Handling appointment.cancelled');
// Send cancellation email
// Release resources
}
private function handleAppointmentValidated(array $eventData): void
{
Log::info('Handling appointment.validated');
// Confirm with staff
}
private function handleAppointmentCompleted(array $eventData): void
{
Log::info('Handling appointment.completed');
// Send satisfaction survey
}
}
Controllers
app/Http/Controllers/AppointmentController.php
<?php
namespace AppHttpControllers;
use AppServicesMonCreneauService;
use IlluminateHttpRequest;
use IlluminateHttpJsonResponse;
use Exception;
class AppointmentController extends Controller
{
public function __construct(
private MonCreneauService $monCreneauService
) {}
public function store(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'departmentId' => 'required|integer',
'dateTime' => 'required|string',
'name' => 'required|string',
]);
$appointment = $this->monCreneauService->createAppointment($validated);
return response()->json($appointment, 201);
} catch (Exception $e) {
return response()->json([
'error' => $e->getMessage(),
], $e->getCode() ?: 500);
}
}
public function show(int $id): JsonResponse
{
try {
$appointment = $this->monCreneauService->getAppointment($id);
return response()->json($appointment);
} catch (Exception $e) {
return response()->json([
'error' => $e->getMessage(),
], 404);
}
}
public function destroy(int $id): JsonResponse
{
try {
$this->monCreneauService->cancelAppointment($id);
return response()->json([
'message' => 'Appointment cancelled',
]);
} catch (Exception $e) {
return response()->json([
'error' => $e->getMessage(),
], 500);
}
}
}
app/Http/Controllers/WebhookController.php
<?php
namespace AppHttpControllers;
use AppServicesWebhookEventService;
use IlluminateHttpRequest;
use IlluminateHttpResponse;
use IlluminateSupportFacadesLog;
use Exception;
class WebhookController extends Controller
{
public function __construct(
private WebhookEventService $webhookEventService
) {}
public function handle(Request $request): Response
{
try {
// Signature already verified by middleware
$event = $request->all();
// Process asynchronously (use queues in production)
dispatch(function () use ($event) {
$this->webhookEventService->processEvent(
$event['type'],
$event['data']
);
})->afterResponse();
return response('OK', 200);
} catch (Exception $e) {
Log::error('Error processing webhook', [
'error' => $e->getMessage(),
]);
return response('Error', 500);
}
}
}
Middleware
app/Http/Middleware/VerifyWebhookSignature.php
<?php
namespace AppHttpMiddleware;
use Closure;
use IlluminateHttpRequest;
use IlluminateSupportFacadesLog;
use SymfonyComponentHttpFoundationResponse;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
$signature = $request->header('X-Moncreneau-Signature');
if (!$signature || !str_starts_with($signature, 'sha256=')) {
Log::warning('Invalid webhook signature format');
return response('Invalid signature', 401);
}
$receivedHash = substr($signature, 7);
$payload = $request->getContent();
$secret = config('moncreneau.webhook_secret');
$expectedHash = hash_hmac('sha256', $payload, $secret);
// Timing-safe comparison
if (!hash_equals($expectedHash, $receivedHash)) {
Log::warning('Webhook signature verification failed');
return response('Invalid signature', 401);
}
return $next($request);
}
}
Routes
routes/api.php
<?php
use AppHttpControllersAppointmentController;
use AppHttpControllersWebhookController;
use IlluminateSupportFacadesRoute;
// Appointments
Route::post('/appointments', [AppointmentController::class, 'store']);
Route::get('/appointments/{id}', [AppointmentController::class, 'show']);
Route::delete('/appointments/{id}', [AppointmentController::class, 'destroy']);
// Webhooks (with signature verification)
Route::post('/webhooks/moncreneau', [WebhookController::class, 'handle'])
->middleware('verify.webhook.signature');
Tests
tests/Unit/WebhookSignatureTest.php
<?php
namespace TestsUnit;
use TestsTestCase;
class WebhookSignatureTest extends TestCase
{
public function test_generates_valid_signature(): void
{
$secret = 'test_secret';
$payload = json_encode(['id' => 'evt_123', 'type' => 'test']);
$signature = 'sha256=' . hash_hmac('sha256', $payload, $secret);
$this->assertStringStartsWith('sha256=', $signature);
$this->assertEquals(71, strlen($signature)); // sha256= (7) + 64 hex chars
}
}
tests/Feature/WebhookTest.php
<?php
namespace TestsFeature;
use TestsTestCase;
class WebhookTest extends TestCase
{
private function signPayload(string $payload): string
{
$secret = config('moncreneau.webhook_secret');
return 'sha256=' . hash_hmac('sha256', $payload, $secret);
}
public function test_webhook_accepts_valid_signature(): void
{
$payload = json_encode([
'id' => 'evt_test_123',
'type' => 'appointment.created',
'data' => ['object' => ['id' => 123]],
]);
$signature = $this->signPayload($payload);
$response = $this->withHeaders([
'X-Moncreneau-Signature' => $signature,
'Content-Type' => 'application/json',
])->post('/api/webhooks/moncreneau', json_decode($payload, true));
$response->assertStatus(200);
}
public function test_webhook_rejects_invalid_signature(): void
{
$response = $this->withHeaders([
'X-Moncreneau-Signature' => 'sha256=invalid',
'Content-Type' => 'application/json',
])->post('/api/webhooks/moncreneau', ['id' => 'evt_test']);
$response->assertStatus(401);
}
}
Bootstrap Middleware
app/Http/Kernel.php
protected $middlewareAliases = [
// ... other middleware
'verify.webhook.signature' => AppHttpMiddlewareVerifyWebhookSignature::class,
];
Getting Started
# 1. Installation
composer install
# 2. Configuration
cp .env.example .env
php artisan key:generate
# Edit .env with your API keys
# 3. Development
php artisan serve
# 4. Testing
php artisan test
# With coverage
php artisan test --coverage
Docker (optional)
FROM php:8.2-fpm
WORKDIR /var/www
RUN apt-get update && apt-get install -y git curl libpng-dev libonig-dev libxml2-dev zip unzip
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
COPY . .
RUN composer install --no-dev --optimize-autoloader
EXPOSE 9000
CMD ["php-fpm"]