Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,47 @@ OpenAI::assertSent(Responses::class, function (string $method, array $parameters

For more testing examples, take a look at the [openai-php/client](https://github.com/openai-php/client#testing) repository.

## Webhooks

You can easily handle OpenAI webhooks using the built-in webhook receiver. Start by enabling the webhook receiver:

```env
OPENAI_WEBHOOKS_ENABLED=true
```

Register the webhook on your OpenAI dashboard, at https://openai.com.

### Webhook Endpoint

By default, the webhook endpoint is configured to `/openai/webhook`. You can customize this URL by changing the `openai.webhook.path` configuration value.

```env
OPENAI_WEBHOOK_URI=/openai/webhook
```

### Webhook Signing Secret

Then, add your webhook signing secret to your `.env` file:

```env
OPENAI_WEBHOOK_SECRET=whsec_...
```

Now you're ready to handle incoming webhooks. All events received will be dispatched as Laravel events, which you can listen to in your application.

```php
use OpenAI\Laravel\Events\WebhookReceived;
use Illuminate\Support\Facades\Event;

Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
if ($event->type === WebhookEventType::ResponseCompleted) {
$payload = $event->payload;

// Handle the completed response...
}
});
```

---

OpenAI PHP for Laravel is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
"php": "^8.2.0",
"guzzlehttp/guzzle": "^7.9.3",
"laravel/framework": "^11.29|^12.12",
"openai-php/client": "^0.18.0"
"openai-php/client": "dev-main",
"symfony/psr-http-message-bridge": "^8.0.0"
},
"require-dev": {
"laravel/pint": "^1.22.0",
"orchestra/testbench": "^10.8.0",
"pestphp/pest": "^3.8.2|^4.0.0",
"pestphp/pest-plugin-arch": "^3.1.1|^4.0.0",
"phpstan/phpstan": "^2.1",
Expand Down
46 changes: 46 additions & 0 deletions config/openai.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,50 @@
*/

'request_timeout' => env('OPENAI_REQUEST_TIMEOUT', 30),

'webhook' => [
/*
|--------------------------------------------------------------------------
| Webhook
|--------------------------------------------------------------------------
| This option controls whether the OpenAI webhook endpoint is
| enabled. Set this to true to enable automatic handling of webhook
| requests from OpenAI.
*/
'enabled' => env('OPENAI_WEBHOOK_ENABLED', false),

/*
|--------------------------------------------------------------------------
| Webhook URI / Subdomain
|--------------------------------------------------------------------------
|
| This value is the URI path where OpenAI will send webhook requests to.
| You may change this path to anything you like. Make sure to update
| your OpenAI webhook settings to match this URI.
| If necessary, you may also specify a custom domain for the webhook route.
*/
'uri' => env('OPENAI_WEBHOOK_URI', '/openai/webhook'),
'domain' => env('OPENAI_WEBHOOK_DOMAIN'),

/*
|--------------------------------------------------------------------------
| Webhook Middleware
|--------------------------------------------------------------------------
| Here you may specify the middleware that will be applied to
| the OpenAI webhook route.
| Note that the signature verification middleware is always applied.
*/
'middleware' => env('OPENAI_WEBHOOK_MIDDLEWARE', 'web'),

/*
|--------------------------------------------------------------------------
| Webhook Signing secret
|--------------------------------------------------------------------------
|
| This value is the signing secret used to verify incoming webhook
| requests from OpenAI. You can find this secret in your OpenAI
| dashboard, in the webhook settings for your application.
*/
'secret' => env('OPENAI_WEBHOOK_SECRET'),
],
];
3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
>
<php>
<env name="APP_KEY" value="base64:dxPp4telCysptcFUIG6Vf2Jm8LXtTixZzsBMPYPZl8I="/>
</php>
<testsuites>
<testsuite name="Default Test Suite">
<directory suffix=".php">./tests</directory>
Expand Down
14 changes: 14 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

use Illuminate\Support\Facades\Route;
use OpenAI\Laravel\Http\Controllers\WebhookController;
use OpenAI\Laravel\Http\Middleware\VerifyWebhookSignature;

Route::group(['middleware' => config('openai.webhook.middleware', ['web'])], function () {
$webhookUri = config('openai.webhook.uri', '/openai/webhook');
assert(is_string($webhookUri));

Route::post($webhookUri, WebhookController::class)
->name('webhook')
->middleware(VerifyWebhookSignature::class);
});
19 changes: 19 additions & 0 deletions src/Events/WebhookReceived.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace OpenAI\Laravel\Events;

use DateTimeInterface;
use OpenAI\Enums\Webhooks\WebhookEvent;

readonly class WebhookReceived
{
/**
* @param array<array-key, mixed> $payload
*/
public function __construct(
public WebhookEvent $type,
public string $id,
public DateTimeInterface $timestamp,
public array $payload,
) {}
}
26 changes: 26 additions & 0 deletions src/Http/Controllers/WebhookController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace OpenAI\Laravel\Http\Controllers;

use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use OpenAI\Laravel\Events\WebhookReceived;
use OpenAI\Laravel\Http\Requests\WebhookRequest;

class WebhookController extends Controller
{
public function __invoke(WebhookRequest $request, Dispatcher $dispatcher): Response
{
$dispatcher->dispatch(
new WebhookReceived(
$request->getEventType(),
$request->getEventId(),
$request->getTimestamp(),
$request->getData(),
),
);

return response()->noContent(202);
}
}
41 changes: 41 additions & 0 deletions src/Http/Middleware/VerifyWebhookSignature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace OpenAI\Laravel\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use OpenAI\Exceptions\WebhookVerificationException;
use OpenAI\Webhooks\WebhookSignatureVerifier;
use RuntimeException;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

readonly class VerifyWebhookSignature
{
public function __construct(
private WebhookSignatureVerifier $verifier,
private PsrHttpFactory $psrBridge,
) {}

/**
* Handle an incoming request.
*
* @throws AccessDeniedHttpException
* @throws RuntimeException
*/
public function handle(Request $request, Closure $next): mixed
{
$psrRequest = $this->psrBridge->createRequest($request);

try {
$this->verifier->verify($psrRequest);
} catch (WebhookVerificationException $exception) {
throw new AccessDeniedHttpException(
'Invalid webhook signature',
$exception,
);
}

return $next($request);
}
}
60 changes: 60 additions & 0 deletions src/Http/Requests/WebhookRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace OpenAI\Laravel\Http\Requests;

use DateTimeImmutable;
use DateTimeInterface;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use OpenAI\Enums\Webhooks\WebhookEvent;

class WebhookRequest extends FormRequest
{
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'id' => ['required', 'string', 'starts_with:evt_'],
'type' => ['required', 'string', Rule::enum(WebhookEvent::class)],
'created_at' => ['required', 'integer', 'min:0'],
'data' => ['required', 'array'],
];
}

public function getEventType(): WebhookEvent
{
$type = $this->input('type');
assert(is_string($type));

return WebhookEvent::from($type);
}

public function getEventId(): string
{
$id = $this->input('id');
assert(is_string($id));

return $id;
}

public function getTimestamp(): DateTimeInterface
{
$timestamp = $this->input('created_at');
assert(is_int($timestamp));

return (new DateTimeImmutable)->setTimestamp($timestamp);
}

/**
* @return array<array-key, mixed>
*/
public function getData(): array
{
$data = $this->input('data', []);
assert(is_array($data));

return $data;
}
}
21 changes: 21 additions & 0 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
namespace OpenAI\Laravel;

use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider as BaseServiceProvider;
use OpenAI;
use OpenAI\Client;
use OpenAI\Contracts\ClientContract;
use OpenAI\Laravel\Commands\InstallCommand;
use OpenAI\Laravel\Exceptions\ApiKeyIsMissing;
use OpenAI\Webhooks\WebhookSignatureVerifier;

/**
* @internal
Expand Down Expand Up @@ -50,6 +52,11 @@ public function register(): void

$this->app->alias(ClientContract::class, 'openai');
$this->app->alias(ClientContract::class, Client::class);

$this->app
->when(WebhookSignatureVerifier::class)
->needs('$secret')
->give(fn () => config('openai.webhook.secret'));
}

/**
Expand All @@ -66,6 +73,19 @@ public function boot(): void
InstallCommand::class,
]);
}

$this->registerRoutes();
}

private function registerRoutes(): void
{
if (config('openai.webhook.enabled')) {
Route::group([
'namespace' => 'OpenAI\Laravel\Http\Controllers',
'domain' => config('openai.webhook.domain'),
'as' => 'openai.',
], fn () => $this->loadRoutesFrom(__DIR__.'/../routes/web.php'));
}
}

/**
Expand All @@ -76,6 +96,7 @@ public function boot(): void
public function provides(): array
{
return [
WebhookSignatureVerifier::class,
Client::class,
ClientContract::class,
'openai',
Expand Down
2 changes: 2 additions & 0 deletions tests/Arch.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'OpenAI\Contracts\ResponseContract',
'OpenAI\Laravel\Testing\OpenAIFake',
'OpenAI\Responses\StreamResponse',
'Illuminate\Support\Facades\Route',
]);

test('service providers')
Expand All @@ -21,6 +22,7 @@
'OpenAI\Laravel',
'OpenAI',
'Illuminate\Contracts\Support\DeferrableProvider',
'Illuminate\Support\Facades\Route',

// helpers...
'config',
Expand Down
19 changes: 19 additions & 0 deletions tests/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use OpenAI\Contracts\ClientContract;
use OpenAI\Laravel\Exceptions\ApiKeyIsMissing;
use OpenAI\Laravel\ServiceProvider;
use OpenAI\Webhooks\WebhookSignatureVerifier;

it('binds the client on the container', function () {
$app = app();
Expand Down Expand Up @@ -55,8 +56,26 @@
$provides = (new ServiceProvider($app))->provides();

expect($provides)->toBe([
WebhookSignatureVerifier::class,
Client::class,
ClientContract::class,
'openai',
]);
});

it('provides the webhook secret to the signature verifier', function () {
$app = app();

$app->bind('config', fn () => new Repository([
'openai' => [
'api_key' => 'test',
'webhook' => [
'secret' => 'whsec_abcd1234',
],
],
]));

(new ServiceProvider($app))->register();

expect($app->get(WebhookSignatureVerifier::class))->toBeInstanceOf(WebhookSignatureVerifier::class);
});
Loading
Loading