@orieljs/framework 0.1.0-alpha.1 → 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/NOTICE +7 -0
  2. package/README.md +10 -1
  3. package/TRADEMARKS.md +33 -0
  4. package/dist/chunk-7ZQZYDOC.js +3253 -0
  5. package/dist/chunk-7ZQZYDOC.js.map +1 -0
  6. package/dist/{chunk-K6MAA3RM.js → chunk-GVNLZZTN.js} +7 -1
  7. package/dist/chunk-GVNLZZTN.js.map +1 -0
  8. package/dist/chunk-OXAATQA6.js +8433 -0
  9. package/dist/chunk-OXAATQA6.js.map +1 -0
  10. package/dist/{chunk-WI7E7Q46.js → chunk-VOYW3R5J.js} +8 -4
  11. package/dist/chunk-VOYW3R5J.js.map +1 -0
  12. package/dist/contracts.d.ts +841 -168
  13. package/dist/contracts.js +7 -1
  14. package/dist/index.d-WQpfdIbG.d.ts +630 -0
  15. package/dist/index.d.ts +2916 -5178
  16. package/dist/index.js +7357 -2609
  17. package/dist/index.js.map +1 -1
  18. package/dist/{jsx-runtime-B8J6xq-7.d.ts → jsx-runtime-6um4Mwfb.d.ts} +99 -149
  19. package/dist/jsx-runtime.d.ts +3 -1
  20. package/dist/jsx-runtime.js +3 -3
  21. package/dist/testing-CKwfWJGn.d.ts +1586 -0
  22. package/dist/testing.d.ts +3 -1408
  23. package/dist/testing.js +29 -5
  24. package/docs/authentication.md +487 -0
  25. package/docs/authorization.md +435 -0
  26. package/docs/cache.md +647 -0
  27. package/docs/collections.md +2270 -0
  28. package/docs/configuration.md +334 -0
  29. package/docs/console.md +652 -0
  30. package/docs/container.md +430 -0
  31. package/docs/controllers.md +406 -0
  32. package/docs/csrf.md +126 -0
  33. package/docs/database-testing.md +144 -0
  34. package/docs/database.md +236 -0
  35. package/docs/deployment.md +276 -0
  36. package/docs/eloquent-collections.md +218 -0
  37. package/docs/eloquent-factories.md +255 -0
  38. package/docs/eloquent-mutators.md +254 -0
  39. package/docs/eloquent-observers.md +145 -0
  40. package/docs/eloquent-relationships.md +557 -0
  41. package/docs/eloquent.md +494 -0
  42. package/docs/encryption.md +104 -0
  43. package/docs/errors.md +318 -0
  44. package/docs/events.md +336 -0
  45. package/docs/facades.md +278 -0
  46. package/docs/hashing.md +118 -0
  47. package/docs/helpers.md +1281 -0
  48. package/docs/http-tests.md +386 -0
  49. package/docs/inertia.md +485 -0
  50. package/docs/installation.md +209 -0
  51. package/docs/lifecycle.md +234 -0
  52. package/docs/live-tests.md +226 -0
  53. package/docs/live.md +910 -0
  54. package/docs/logging.md +255 -0
  55. package/docs/mail.md +619 -0
  56. package/docs/middleware.md +384 -0
  57. package/docs/migrations.md +416 -0
  58. package/docs/mocking.md +264 -0
  59. package/docs/navigation.json +273 -0
  60. package/docs/notifications.md +225 -0
  61. package/docs/pagination.md +247 -0
  62. package/docs/passwords.md +207 -0
  63. package/docs/providers.md +187 -0
  64. package/docs/queries.md +600 -0
  65. package/docs/queues.md +330 -0
  66. package/docs/requests.md +533 -0
  67. package/docs/responses.md +444 -0
  68. package/docs/routing.md +738 -0
  69. package/docs/scheduling.md +277 -0
  70. package/docs/seeding.md +122 -0
  71. package/docs/session.md +409 -0
  72. package/docs/starter-kits.md +396 -0
  73. package/docs/storage.md +192 -0
  74. package/docs/structure.md +264 -0
  75. package/docs/testing.md +165 -0
  76. package/docs/time-travel.md +138 -0
  77. package/docs/validation.md +1792 -0
  78. package/docs/verification.md +152 -0
  79. package/docs/views.md +566 -0
  80. package/docs/vite.md +322 -0
  81. package/package.json +35 -30
  82. package/dist/chunk-AI2RFMBT.js +0 -745
  83. package/dist/chunk-AI2RFMBT.js.map +0 -1
  84. package/dist/chunk-BZPCRKFQ.js +0 -2941
  85. package/dist/chunk-BZPCRKFQ.js.map +0 -1
  86. package/dist/chunk-K6MAA3RM.js.map +0 -1
  87. package/dist/chunk-WI7E7Q46.js.map +0 -1
package/docs/errors.md ADDED
@@ -0,0 +1,318 @@
1
+ # Error handling
2
+
3
+ When a request ends in an error, Oriel logs it if it should and answers with an error page or JSON.
4
+ `withExceptions()` changes both.
5
+
6
+ ## Introduction
7
+
8
+ A new app handles errors with no setup: an unexpected error that a route or a middleware throws is reported to your
9
+ log, and the user gets a response with the error's status. To change how errors are reported and rendered, give
10
+ `withExceptions()` in `bootstrap/app.ts` a function that receives the configurator, as the rest of this page shows.
11
+
12
+ ## Configuration
13
+
14
+ `debug` in `config/app.ts` decides how much an error response shows. It reads `APP_DEBUG` from your `.env` (see [Debug
15
+ mode](configuration.md#debug-mode)), and is `false` unless the variable says otherwise:
16
+
17
+ ```ts title="config/app.ts"
18
+ import { env } from '@orieljs/framework'
19
+
20
+ export default {
21
+ name: env('APP_NAME', 'Oriel'),
22
+ env: env('APP_ENV', 'production'),
23
+ debug: env('APP_DEBUG', false),
24
+ url: env('APP_URL', 'http://localhost'),
25
+ timezone: 'UTC',
26
+ locale: env('APP_LOCALE', 'en'),
27
+ cipher: 'AES-256-GCM',
28
+ key: env('APP_KEY'),
29
+ previous_keys: String(env('APP_PREVIOUS_KEYS', '')).split(',').filter(Boolean),
30
+ }
31
+ ```
32
+
33
+ A new app's `.env` sets `APP_DEBUG=true`, for your machine. With it, a server error shows the debug page: the error's
34
+ class and message, the request's method and URL, the lines of your code around where it was thrown, the stack, and
35
+ the errors that caused it. A request that expects JSON gets the message, the error's name, the file, the line and
36
+ the stack. Errors with an HTTP status, such as a 404, get their usual page either way.
37
+
38
+ > [!WARNING]
39
+ > In production, `APP_DEBUG` must be `false`. The debug page shows your code and your error messages, which can hold
40
+ > configuration values and other secrets, to anyone who triggers an error.
41
+
42
+ ## Handling exceptions
43
+
44
+ ### Reporting exceptions
45
+
46
+ Reporting an error writes it to your [log](logging.md), at the `error` level, with the error itself as the entry's
47
+ `exception` context. `report()` registers a function that receives each error to report, to send it somewhere else
48
+ too, such as an error tracking service:
49
+
50
+ ```ts title="bootstrap/app.ts"
51
+ // @filename: app/Exceptions/InvalidOrderException.ts
52
+ export class InvalidOrderException extends Error {}
53
+ // ---cut---
54
+ import { dirname } from 'node:path'
55
+ import { Application } from '@orieljs/framework'
56
+ import { InvalidOrderException } from '../app/Exceptions/InvalidOrderException'
57
+ import providers from './providers'
58
+
59
+ export function createApplication(): Promise<Application> {
60
+ return Application.configure({ basePath: dirname(import.meta.dirname) })
61
+ .withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' })
62
+ .withExceptions((exceptions) => {
63
+ exceptions.report((error) => {
64
+ if (error instanceof InvalidOrderException) {
65
+ // ...
66
+ }
67
+ })
68
+ })
69
+ .withProviders(providers)
70
+ .create()
71
+ }
72
+ ```
73
+
74
+ The error is still logged afterwards. Return `false` from the function to stop that, and the functions registered
75
+ after it:
76
+
77
+ ```ts
78
+ declare const exceptions: ExceptionsConfigurator
79
+ declare class InvalidOrderException extends Error {}
80
+ // ---cut---
81
+ exceptions.report((error) => {
82
+ if (error instanceof InvalidOrderException) {
83
+ // ...
84
+
85
+ return false
86
+ }
87
+ })
88
+ ```
89
+
90
+ > [!NOTE]
91
+ > Laravel calls a `report()` function only for the errors of the class its parameter type-hints. TypeScript types are
92
+ > gone when your app runs, so Oriel calls every function for every error it reports: check the error's class with
93
+ > `instanceof`, as above.
94
+
95
+ Each error is reported once, however many times it reaches the handler, as in Laravel after `dontReportDuplicates()`.
96
+ Laravel's global and per-exception log context, `context()`, isn't in Oriel; [Logging](logging.md) shows how to add
97
+ context to every log entry.
98
+
99
+ #### Reporting an error yourself
100
+
101
+ Oriel has no `report()` helper. To report an error and carry on with the request, call the exception handler's
102
+ `report()`:
103
+
104
+ ```ts
105
+ declare function validateTheValue(value: string): void
106
+ // ---cut---
107
+ async function isValid(value: string): Promise<boolean> {
108
+ try {
109
+ validateTheValue(value)
110
+
111
+ return true
112
+ } catch (error) {
113
+ await app().make(ExceptionHandler).report(error)
114
+
115
+ return false
116
+ }
117
+ }
118
+ ```
119
+
120
+ ### Exception log levels
121
+
122
+ Every report is logged at the `error` level. Laravel's `level()`, which changes the level by the error's class, isn't
123
+ in Oriel: log the error yourself from a `report()` function, at the level you want, and return `false`.
124
+
125
+ ### Ignoring exceptions by type
126
+
127
+ `dontReport()` takes the error classes never to report. They are still rendered as usual:
128
+
129
+ ```ts
130
+ declare const exceptions: ExceptionsConfigurator
131
+ declare class InvalidOrderException extends Error {}
132
+ // ---cut---
133
+ exceptions.dontReport(InvalidOrderException)
134
+ ```
135
+
136
+ Oriel never reports the errors that are part of normal requests: an `abort()`, a missing route or model, a failed
137
+ validation, an unauthenticated or unauthorized user, and a CSRF token mismatch. Laravel's `dontReportWhen()`,
138
+ `stopIgnoring()` and `ShouldntReport` interface aren't in Oriel.
139
+
140
+ ### Rendering exceptions
141
+
142
+ `render()` registers a function that turns an error into a response. It receives the error and the request, and
143
+ returns a `Response`, or nothing to leave the error to the default rendering:
144
+
145
+ ```ts title="bootstrap/app.ts"
146
+ // @filename: app/Exceptions/InvalidOrderException.ts
147
+ export class InvalidOrderException extends Error {}
148
+ // ---cut---
149
+ import { dirname } from 'node:path'
150
+ import { Application, response, View } from '@orieljs/framework'
151
+ import { InvalidOrderException } from '../app/Exceptions/InvalidOrderException'
152
+ import providers from './providers'
153
+
154
+ export function createApplication(): Promise<Application> {
155
+ return Application.configure({ basePath: dirname(import.meta.dirname) })
156
+ .withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' })
157
+ .withExceptions((exceptions) => {
158
+ exceptions.render(async (error) => {
159
+ if (error instanceof InvalidOrderException) {
160
+ return response(await View.make('errors.invalid-order').render(), 500)
161
+ }
162
+ })
163
+ })
164
+ .withProviders(providers)
165
+ .create()
166
+ }
167
+ ```
168
+
169
+ The functions run first, for every error, so they can change how a built-in error renders too:
170
+
171
+ ```ts
172
+ declare const exceptions: ExceptionsConfigurator
173
+ // ---cut---
174
+ exceptions.render((error, request) => {
175
+ if (error instanceof ModelNotFoundError && (request as HttpRequest).is('api/*')) {
176
+ return response().json({ message: 'Record not found.' }, 404)
177
+ }
178
+ })
179
+ ```
180
+
181
+ The request is typed `unknown`: during a request it is the request `request()` returns.
182
+
183
+ #### Rendering exceptions as JSON
184
+
185
+ An error is rendered as JSON when the request expects JSON: its `Accept` header asks for JSON first, or it is an
186
+ AJAX request that accepts anything (see [content negotiation](requests.md#content-negotiation)). Otherwise it gets
187
+ an HTML page. With `shouldRenderJsonWhen()`, you decide it yourself; the API starter kit answers JSON for every route
188
+ under `/api`:
189
+
190
+ ```ts
191
+ declare const exceptions: ExceptionsConfigurator
192
+ // ---cut---
193
+ exceptions.shouldRenderJsonWhen((request) => request.is('api/*') || request.expectsJson())
194
+ ```
195
+
196
+ Laravel's `respond()`, which changes every response the handler renders, isn't in Oriel: use `render()`.
197
+
198
+ ### Renderable exceptions
199
+
200
+ An error class of your own can render itself. Give it a `render()` method that returns a `Response`:
201
+
202
+ ```ts title="app/Exceptions/InvalidOrderException.ts"
203
+ import { response } from '@orieljs/framework'
204
+
205
+ export class InvalidOrderException extends Error {
206
+ /** Render the exception as an HTTP response. */
207
+ render(request: unknown): Response {
208
+ return response('The order could not be processed.', 422)
209
+ }
210
+ }
211
+ ```
212
+
213
+ When `render()` returns something that isn't a `Response`, the error is rendered as usual. A `toResponse()` method
214
+ works the same way. The error is still reported, unless its class is in `dontReport()`. Laravel's reportable
215
+ exceptions, with a `report()` method of their own, aren't in Oriel: use a `report()` function in `withExceptions()`.
216
+
217
+ Laravel's `throttle()`, which samples or rate limits the reports, isn't in Oriel either.
218
+
219
+ ## HTTP exceptions
220
+
221
+ `abort()` ends the request with an HTTP status, from anywhere in your code:
222
+
223
+ ```ts
224
+ abort(404)
225
+ ```
226
+
227
+ A message and headers can follow. The message is what JSON responses show; the HTML pages show it for a 403 only:
228
+
229
+ ```ts
230
+ abort(403, 'You may not edit this post.')
231
+
232
+ abort(503, 'Down for maintenance', { 'Retry-After': '120' })
233
+ ```
234
+
235
+ `abortIf()` and `abortUnless()` abort when a condition is true, or false:
236
+
237
+ ```ts
238
+ declare const user: User | undefined
239
+ declare const post: Post
240
+ // ---cut---
241
+ abortIf(user === undefined, 403)
242
+
243
+ abortUnless(post.published_at !== null, 404)
244
+ ```
245
+
246
+ `abort()` also takes a ready `Response`, which is sent as it is: `abort(response('Gone fishing', 418))`.
247
+
248
+ ### Statuses by error
249
+
250
+ Oriel's own errors carry their statuses. Each gets the same page, or JSON, as an `abort()` with that status:
251
+
252
+ | Error | Status |
253
+ | --- | --- |
254
+ | No route matches the URL | 404 |
255
+ | The route doesn't take the method | 405, with an `Allow` header |
256
+ | A model not found, by `findOrFail()` or [route model binding](routing.md#route-model-binding) | 404 |
257
+ | A guest on a route with the `auth` middleware | 401 as JSON; otherwise a redirect to the login page |
258
+ | An authorization check that fails | 403, or the status the policy chose |
259
+ | A form without its [CSRF token](csrf.md) | 419 |
260
+ | A failed [validation](validation.md) | 422 as JSON; otherwise a redirect back with the errors |
261
+ | Too many requests on a `throttle` route | 429, with `Retry-After` and the `X-RateLimit-*` headers |
262
+ | A body over the [size limit](requests.md#body-size-limits) | 413 |
263
+ | A JSON body nested too deeply | 400 |
264
+ | Anything else | 500 |
265
+
266
+ None of these is reported, except the last. The login page a guest is sent to is `http.redirect_guests_to`, set with
267
+ [`redirectGuestsTo()`](middleware.md#redirecting-guests-and-signed-in-users), else the route named `login`, else
268
+ `/login`; the URL the guest wanted is kept for after they sign in.
269
+
270
+ ### Custom HTTP error pages
271
+
272
+ To show your own page for a status, add a view named after it: `resources/views/errors/404.tsx` for a 404. The view
273
+ receives the `status`, the `message` and the `exception`:
274
+
275
+ ```tsx title="resources/views/errors/404.tsx"
276
+ interface Props {
277
+ status: number
278
+ message: string
279
+ exception: unknown
280
+ }
281
+
282
+ export default function NotFound({ status, message, exception }: Props) {
283
+ return (
284
+ <html lang="en">
285
+ <body>
286
+ <h1>{status}</h1>
287
+ <h2>{exception instanceof Error && exception.message !== '' ? exception.message : message}</h2>
288
+ </body>
289
+ </html>
290
+ )
291
+ }
292
+ ```
293
+
294
+ `message` is the status's text, "Not Found", except for a 403, where it is the error's message. The error's own
295
+ message is on `exception`, and is empty for an `abort(404)` without one. A new app has no error views. Without one,
296
+ the page is a minimal one with the status and its text, as `404 | Not Found`.
297
+
298
+ The pages are views like any other, so they can use your layout. A view that fails to render is reported, and the
299
+ minimal page is sent in its place.
300
+
301
+ #### Fallback HTTP error pages
302
+
303
+ `resources/views/errors/4xx.tsx` is the page for any client error without a page of its own, and `5xx.tsx` for any
304
+ server error.
305
+
306
+ > [!NOTE]
307
+ > Laravel's fallback pages don't cover 404, 500 and 503, which have pages of their own inside the framework. Oriel
308
+ > has none of those, so `4xx.tsx` and `5xx.tsx` cover every status without its own view.
309
+
310
+ ### What a production error shows
311
+
312
+ With `debug` off, a server error shows nothing of the error: your `errors/500.tsx` or `errors/5xx.tsx` view, or the
313
+ minimal "500 | Server Error", and `{ "message": "Server Error" }` to a request that expects JSON. The error is
314
+ reported to your log with its stack.
315
+
316
+ An error with an HTTP status shows its status text on the HTML page (a 403 shows its message), and its message in
317
+ JSON: `{ "message": "No query results for model [Post] 5" }` for a missing `Post`. Keep secrets out of the messages
318
+ you give `abort()`.
package/docs/events.md ADDED
@@ -0,0 +1,336 @@
1
+ # Events
2
+
3
+ Events let one part of your app announce that something happened, and let other parts act on it, without the two
4
+ knowing about each other.
5
+
6
+ ## Introduction
7
+
8
+ An event is a plain class that carries what happened: an `OrderShipped` event carries the order. A listener is a
9
+ class with a `handle()` method that the event is passed to. One event can have many listeners: when an order ships,
10
+ one listener emails the customer and another tells the warehouse, and the code that ships the order dispatches the
11
+ event and knows about neither.
12
+
13
+ Events live in `app/Events` and listeners in `app/Listeners`. Neither folder exists in a new app: the `make:event` and
14
+ `make:listener` commands create them.
15
+
16
+ ## Generating events and listeners
17
+
18
+ ```sh
19
+ node oriel make:event OrderShipped
20
+ node oriel make:listener SendShipmentNotification --event=OrderShipped
21
+ ```
22
+
23
+ `make:listener` with `--event` writes a listener that already names its event in `static listensTo`, so
24
+ [discovery](#event-discovery) registers it. Add `--queued` for a [queued listener](#queued-event-listeners).
25
+
26
+ ## Registering events and listeners
27
+
28
+ ### Event discovery
29
+
30
+ When your app boots, Oriel imports every file under `app/Listeners`, folders included, and registers each exported
31
+ class that has a `handle()` method and a static `listensTo` field. `listensTo` names the event the listener handles:
32
+ an event class, an event's name as a string, or an array of either.
33
+
34
+ ```ts title="app/Listeners/SendShipmentNotification.ts"
35
+ // @filename: app/Events/OrderShipped.ts
36
+ import type { Order } from '../Models/Order'
37
+ export class OrderShipped {
38
+ constructor(readonly order: Order) {}
39
+ }
40
+ // ---cut---
41
+ import { OrderShipped } from '../Events/OrderShipped'
42
+
43
+ export class SendShipmentNotification {
44
+ static listensTo = OrderShipped
45
+
46
+ async handle(event: OrderShipped): Promise<void> {
47
+ // Email the customer about event.order...
48
+ }
49
+ }
50
+ ```
51
+
52
+ Any other export in a listener's file, such as a helper function, is skipped.
53
+
54
+ > [!NOTE]
55
+ > Laravel finds a listener's event from the type of its `handle()` method's parameter. TypeScript types are gone when
56
+ > your app runs, so an Oriel listener names its event in `static listensTo`. A listener without it isn't discovered:
57
+ > register it yourself.
58
+
59
+ A new app has no `config/events.ts`. To turn discovery off, add one: `export default { discover: false }`.
60
+
61
+ ### Manually registering events
62
+
63
+ `Event.listen()` registers a listener in a provider's `boot()` method. The listener can be a listener class, a
64
+ `[class, method]` pair, or a function:
65
+
66
+ ```ts title="app/Providers/AppServiceProvider.ts"
67
+ // @filename: app/Events/OrderShipped.ts
68
+ import type { Order } from '../Models/Order'
69
+ export class OrderShipped {
70
+ constructor(readonly order: Order) {}
71
+ }
72
+ // @filename: app/Listeners/SendShipmentNotification.ts
73
+ import type { OrderShipped } from '../Events/OrderShipped'
74
+ export declare class SendShipmentNotification {
75
+ handle(event: OrderShipped): Promise<void>
76
+ }
77
+ // ---cut---
78
+ import { Event, ServiceProvider } from '@orieljs/framework'
79
+ import { OrderShipped } from '../Events/OrderShipped'
80
+ import { SendShipmentNotification } from '../Listeners/SendShipmentNotification'
81
+
82
+ export class AppServiceProvider extends ServiceProvider {
83
+ override boot(): void {
84
+ Event.listen(OrderShipped, SendShipmentNotification)
85
+
86
+ Event.listen(OrderShipped, (event: OrderShipped) => {
87
+ // ...
88
+ })
89
+ }
90
+ }
91
+ ```
92
+
93
+ A listener class registered here and also discovered runs twice, once for each registration.
94
+
95
+ `config/events.ts` can list them too, under `listen`, as `[event, listeners]` pairs (a TypeScript object can't take a
96
+ class as a key):
97
+
98
+ ```ts title="config/events.ts"
99
+ // @filename: app/Events/OrderShipped.ts
100
+ import type { Order } from '../Models/Order'
101
+ export class OrderShipped {
102
+ constructor(readonly order: Order) {}
103
+ }
104
+ // @filename: app/Listeners/SendShipmentNotification.ts
105
+ import type { OrderShipped } from '../Events/OrderShipped'
106
+ export declare class SendShipmentNotification {
107
+ handle(event: OrderShipped): Promise<void>
108
+ }
109
+ // ---cut---
110
+ import type { EventListenMap } from '@orieljs/framework'
111
+ import { OrderShipped } from '../app/Events/OrderShipped'
112
+ import { SendShipmentNotification } from '../app/Listeners/SendShipmentNotification'
113
+
114
+ export default {
115
+ discover: true,
116
+ listen: [[OrderShipped, [SendShipmentNotification]]] as EventListenMap,
117
+ }
118
+ ```
119
+
120
+ #### Wildcard listeners
121
+
122
+ A listener for a string event can use `*` as a wildcard. It is called with the event's name and the array of its
123
+ payload:
124
+
125
+ ```ts
126
+ import { Event } from '@orieljs/framework'
127
+
128
+ Event.listen('order.*', (eventName: string, payload?: unknown[]) => {
129
+ // ...
130
+ })
131
+ ```
132
+
133
+ ### Viewing the events
134
+
135
+ `event:list` lists every event with how many listeners it has:
136
+
137
+ ```sh
138
+ node oriel event:list
139
+ ```
140
+
141
+ ## Defining events
142
+
143
+ An event holds the data its listeners need, and nothing else:
144
+
145
+ ```ts title="app/Events/OrderShipped.ts"
146
+ import type { Order } from '../Models/Order'
147
+
148
+ export class OrderShipped {
149
+ constructor(readonly order: Order) {}
150
+ }
151
+ ```
152
+
153
+ ## Defining listeners
154
+
155
+ A listener's `handle()` method takes the event. It may be async; the dispatch waits for it before it calls the next
156
+ listener.
157
+
158
+ Your app builds a listener class with its container each time the event is dispatched. It is built by the application,
159
+ not by the request's scope, so an `inject()` of a request-scoped service in a listener's field throws during a request.
160
+ Call `inject()` inside `handle()` instead.
161
+
162
+ ### Stopping the propagation of an event
163
+
164
+ A listener that returns `false` stops the listeners after it from running.
165
+
166
+ ## Queued event listeners
167
+
168
+ A listener that sends email or calls another service can run on the queue instead, so the request doesn't wait for it.
169
+ Give it a `readonly shouldQueue = true` field, as `make:listener --queued` does:
170
+
171
+ ```ts title="app/Listeners/SendShipmentNotification.ts"
172
+ // @filename: app/Events/OrderShipped.ts
173
+ import type { Order } from '../Models/Order'
174
+ export class OrderShipped {
175
+ constructor(readonly order: Order) {}
176
+ }
177
+ // ---cut---
178
+ import type { ShouldQueue } from '@orieljs/framework/contracts'
179
+ import { OrderShipped } from '../Events/OrderShipped'
180
+
181
+ export class SendShipmentNotification implements ShouldQueue {
182
+ static listensTo = OrderShipped
183
+
184
+ readonly shouldQueue = true
185
+
186
+ async handle(event: OrderShipped): Promise<void> {
187
+ // ...
188
+ }
189
+ }
190
+ ```
191
+
192
+ When the event is dispatched, the listener is pushed to the queue as a job, with the event, and a worker
193
+ (`node oriel queue:work`) runs it. The queue component must be in your app; read the [Queues](queues.md) page for
194
+ workers and connections.
195
+
196
+ ### Customizing the queue connection, name and delay
197
+
198
+ A queued listener takes the job options as fields: `connection`, `queue`, `delay`, `tries`, `backoff` and `timeout`.
199
+ Each can be a method that returns the value. A listener with any of these fields is queued even without
200
+ `shouldQueue`.
201
+
202
+ ```ts
203
+ export class SendShipmentNotification {
204
+ readonly shouldQueue = true
205
+ readonly connection = 'redis'
206
+ readonly queue = 'listeners'
207
+ readonly delay = 60
208
+
209
+ async handle(): Promise<void> {
210
+ // ...
211
+ }
212
+ }
213
+ ```
214
+
215
+ ### Unique listeners
216
+
217
+ A queued listener with a `uniqueId()` method is unique (so is one with `uniqueFor`, `uniqueUntilProcessing` or
218
+ `uniqueVia()`, whose id is then empty): while one of its jobs is waiting or running, another dispatch with the same id
219
+ isn't queued. `uniqueId()` is called with the event, when the event is dispatched:
220
+
221
+ ```ts title="app/Listeners/SendShipmentNotification.ts"
222
+ // @filename: app/Events/OrderShipped.ts
223
+ import type { Order } from '../Models/Order'
224
+ export class OrderShipped {
225
+ constructor(readonly order: Order) {}
226
+ }
227
+ // ---cut---
228
+ import { OrderShipped } from '../Events/OrderShipped'
229
+
230
+ export class SendShipmentNotification {
231
+ static listensTo = OrderShipped
232
+
233
+ readonly shouldQueue = true
234
+ readonly uniqueFor = 3600
235
+
236
+ uniqueId(event: OrderShipped): string {
237
+ return String(event.order.getKey())
238
+ }
239
+
240
+ async handle(event: OrderShipped): Promise<void> {
241
+ // ...
242
+ }
243
+ }
244
+ ```
245
+
246
+ A duplicate is a second job for the same listener class with the same id. The lock is the listener's, by class: the
247
+ same class listening to two events, or through two methods of a [subscriber](#event-subscribers), shares one lock
248
+ for each id. `uniqueFor` is how many seconds the lock lasts at most, and `uniqueUntilProcessing = true` releases it
249
+ when the job starts rather than when it ends. The lock is a cache lock, so your app's cache store must support locks
250
+ (read the Cache page's [Atomic locks](cache.md#atomic-locks)); `uniqueVia()` returns another store's name.
251
+
252
+ > [!NOTE]
253
+ > Laravel 12 has no unique listeners; Laravel 13 adds them. Oriel's work as Laravel 13's do, and as
254
+ > [unique jobs](queues.md#unique-jobs).
255
+
256
+ ## Dispatching events
257
+
258
+ `Event.dispatch()` dispatches an event, and the `event()` helper does the same. Both wait for every listener that
259
+ isn't queued, and give back the listeners' return values:
260
+
261
+ ```ts
262
+ // @filename: app/Events/OrderShipped.ts
263
+ import type { Order } from '../Models/Order'
264
+ export class OrderShipped {
265
+ constructor(readonly order: Order) {}
266
+ }
267
+ // ---cut---
268
+ import { Event, event } from '@orieljs/framework'
269
+ import { OrderShipped } from './app/Events/OrderShipped'
270
+ import { Order } from './app/Models/Order'
271
+
272
+ const order = await Order.findOrFail(1)
273
+
274
+ await Event.dispatch(new OrderShipped(order))
275
+ await event(new OrderShipped(order))
276
+ ```
277
+
278
+ A string event takes its payload as an array, which its listeners receive as arguments:
279
+ `Event.dispatch('order.shipped', [order])`. `Event.until()` stops at the first listener that returns something other
280
+ than `undefined`, and gives back that value.
281
+
282
+ ## Event subscribers
283
+
284
+ A subscriber is one class that listens to several events. Its `subscribe()` method returns a map of event names to the
285
+ method, or methods, that handle them:
286
+
287
+ ```ts title="app/Listeners/UserEventSubscriber.ts"
288
+ export class UserEventSubscriber {
289
+ handleUserLogin(): void {
290
+ // ...
291
+ }
292
+
293
+ handleUserLogout(): void {
294
+ // ...
295
+ }
296
+
297
+ subscribe(): Record<string, string> {
298
+ return {
299
+ 'user.login': 'handleUserLogin',
300
+ 'user.logout': 'handleUserLogout',
301
+ }
302
+ }
303
+ }
304
+ ```
305
+
306
+ The map's keys are strings, so they name string events. For class events, `subscribe()` takes the dispatcher and can
307
+ call its `listen()` with `[UserEventSubscriber, 'handleUserLogin']` pairs. Register a subscriber in a provider's
308
+ `boot()`, with `Event.subscribe(UserEventSubscriber)`; discovery doesn't find subscribers.
309
+
310
+ ## Testing
311
+
312
+ `Event.fake()` swaps the dispatcher for a fake that records events instead of running their listeners. Give it the
313
+ events to fake, and the others still run:
314
+
315
+ ```ts
316
+ // @filename: app/Events/OrderShipped.ts
317
+ import type { Order } from '../Models/Order'
318
+ export class OrderShipped {
319
+ constructor(readonly order: Order) {}
320
+ }
321
+ // ---cut---
322
+ import { Event } from '@orieljs/framework'
323
+ import { OrderShipped } from './app/Events/OrderShipped'
324
+
325
+ const events = Event.fake([OrderShipped])
326
+
327
+ // Ship the order...
328
+
329
+ events.assertDispatched(OrderShipped)
330
+ events.assertDispatchedTimes(OrderShipped, 1)
331
+ events.assertNotDispatched('order.cancelled')
332
+ ```
333
+
334
+ The fake also has `assertNothingDispatched()`, `assertListening(event, listener)` and `dispatched(event)`.
335
+ `EventFake.fakeExcept([OrderShipped])`, from `@orieljs/framework/testing`, fakes every event but the ones you list.
336
+ [Fakes](mocking.md) covers fakes in full.