From 360a837dc88ead6469631f2bdce6b3e088fdeffb Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 9 Jun 2026 15:16:40 +0800 Subject: [PATCH 01/10] docs: update v1.18 documentation --- .vitepress/config/en.ts | 12 +- en/digging-deeper/artisan-console.md | 27 ++- en/digging-deeper/collections.md | 292 ++++++++++++++++++++++ en/digging-deeper/helpers.md | 2 + en/digging-deeper/package-development.md | 33 +++ en/getting-started/compile.md | 12 + en/getting-started/configuration.md | 45 ++++ en/orm/getting-started.md | 32 +++ en/security/authentication.md | 13 +- en/the-basics/validation.md | 296 +++++++++++++---------- en/upgrade/history.md | 1 + en/upgrade/v1.18.md | 167 +++++++++++++ 12 files changed, 796 insertions(+), 136 deletions(-) create mode 100644 en/digging-deeper/collections.md create mode 100644 en/upgrade/v1.18.md diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts index 7afdda7bb..88e585e98 100644 --- a/.vitepress/config/en.ts +++ b/.vitepress/config/en.ts @@ -171,12 +171,12 @@ function sidebarPrologue(): DefaultTheme.SidebarItem[] { function sidebarUpgrade(): DefaultTheme.SidebarItem[] { return [ { - text: 'Upgrading To v1.17 From v1.16', - link: 'v1.17' + text: 'Upgrading To v1.18 From v1.17', + link: 'v1.18' }, { - text: 'Upgrading To v1.16 From v1.15', - link: 'v1.16' + text: 'Upgrading To v1.17 From v1.16', + link: 'v1.17' }, { text: 'History', @@ -301,6 +301,10 @@ function sidebarAdvanced(): DefaultTheme.SidebarItem[] { text: 'Strings', link: 'strings' }, + { + text: 'Collections', + link: 'collections' + }, { text: 'Helpers', link: 'helpers' diff --git a/en/digging-deeper/artisan-console.md b/en/digging-deeper/artisan-console.md index c886c2fb0..920be3001 100644 --- a/en/digging-deeper/artisan-console.md +++ b/en/digging-deeper/artisan-console.md @@ -31,7 +31,7 @@ Then you can simply run your commands like this: artisan make:controller DemoController ``` -You can also use `artisan` shell script like this: +You can also use the `artisan` shell script to run built-in commands. ### Generating Commands @@ -450,6 +450,31 @@ ctx.NewLine() ctx.NewLine(2) ``` +#### Tables + +You may use the `Table` method to render structured data in a tabular format. The method accepts headers and rows, and writes the rendered table directly to the console: + +```go +func (receiver *SendEmails) Handle(ctx console.Context) error { + headers := []string{"ID", "Email", "Status"} + rows := [][]string{ + {"1", "a@example.com", "Queued"}, + {"2", "b@example.com", "Sent"}, + } + + ctx.Table(headers, rows) + + return nil +} +``` + +You can pass a `console.TableOption` as the third argument to customize borders, dimensions, and styles. +```go +ctx.Table(headers, rows, console.TableOption{ + Width: 80, +}) +``` + #### Progress Bars For long-running tasks, it is often helpful to provide the user with some indication of how much time the task will take. You may use the `WithProgressBar` method to display a progress bar. diff --git a/en/digging-deeper/collections.md b/en/digging-deeper/collections.md new file mode 100644 index 000000000..3f3c86aba --- /dev/null +++ b/en/digging-deeper/collections.md @@ -0,0 +1,292 @@ +# Collections + +[[toc]] + +## Introduction + +Goravel provides a fluent collection API through the `github.com/goravel/framework/support/collect` package. Collections make it convenient to filter, transform, aggregate, and inspect slice data with chainable methods. + +The package provides two collection types: + +- `Collection`: an eager collection that works with data already loaded into memory. +- `LazyCollection`: a lazy collection that evaluates pipelines only when a terminal method is called. + +```go +import "github.com/goravel/framework/support/collect" +``` + +## Creating Collections + +Use `collect.New` to create a collection from variadic arguments: + +```go +numbers := collect.New(1, 2, 3, 4, 5) +``` + +Use `collect.Of` to create a collection from an existing slice: + +```go +items := []string{"apple", "banana", "cherry"} +fruits := collect.Of(items) +``` + +You may retrieve all underlying items with `All`: + +```go +fruits.All() // []string{"apple", "banana", "cherry"} +``` + +## Basic Operations + +Collections provide common methods for reading and modifying items: + +```go +numbers := collect.New(1, 2, 3, 4, 5) + +numbers.Count() // 5 +numbers.IsEmpty() // false +numbers.IsNotEmpty() // true +*numbers.First() // 1 +*numbers.Last() // 5 +numbers.Contains(3) // true +numbers.Search(4) // 3 +``` + +Some methods mutate the current collection and return it for chaining: + +```go +numbers.Push(6).Prepend(0) +numbers.All() // []int{0, 1, 2, 3, 4, 5, 6} +``` + +Mutating methods include `Push`, `Prepend`, `Pop`, `Pull`, `Shift`, `Unshift`, `Forget`, `Put`, `Splice`, and `Transform`. Methods such as `Push`, `Prepend`, `Forget`, `Put`, and `Transform` return the current collection, while methods such as `Pop`, `Pull`, `Shift`, and `Splice` return the removed item or items. + +## Filtering And Mapping + +Use `Filter` and `Reject` to keep or remove items by callback: + +```go +numbers := collect.New(1, 2, 3, 4, 5, 6) + +evens := numbers.Filter(func(n int, _ int) bool { + return n%2 == 0 +}) + +evens.All() // []int{2, 4, 6} +``` + +Use `Map` to transform each item. The `Map` method returns `*collect.Collection[any]` because the callback can return any type: + +```go +labels := numbers.Map(func(n int, i int) any { + return fmt.Sprintf("item_%d_%d", i, n) +}) + +labels.All() // []any{"item_0_1", "item_1_2", ...} +``` + +Other transformation helpers include `FlatMap`, `MapInto`, `MapSpread`, `MapToDictionary`, `MapToGroups`, and `MapWithKeys`. + +Use `Unique`, `UniqueBy`, and `Duplicates` to work with repeated values. The `Duplicates` method returns each duplicated value once: + +```go +numbers := collect.New(1, 2, 2, 3, 3, 3) + +numbers.Unique().All() // []int{1, 2, 3} +numbers.Duplicates().All() // []int{2, 3} +``` + +## Working With Structs + +The `Where` method supports callback filtering and Laravel-style field comparisons. + +```go +type User struct { + ID int + Name string + Age int + Country string + Balance float64 + DeletedAt *time.Time +} + +users := collect.Of([]User{ + {ID: 1, Name: "Alice", Age: 25, Country: "FR", Balance: 150}, + {ID: 2, Name: "Bob", Age: 30, Country: "US", Balance: 80}, + {ID: 3, Name: "Charlie", Age: 25, Country: "FR", Balance: 200}, +}) + +frenchUsers := users.Where("Country", "FR") +youngUsers := users.Where("Age", "=", 25) +numericAgeUsers := users.Where("Age", "=", "25") +richUsers := users.Where("Balance", ">", 100) +adultUsers := users.Where(func(user User) bool { + return user.Age >= 18 +}) +``` + +Supported comparison operators are `=`, `==`, `!=`, `>`, `>=`, `<`, `<=`, `like`, and `not like`. Equality comparisons support simple mixed numeric values, so an integer field can match a numeric string such as `"25"`. + +You may also use field-specific helpers: + +```go +activeUsers := users.WhereNull("DeletedAt") +deletedUsers := users.WhereNotNull("DeletedAt") +selectedUsers := users.WhereIn("Country", []any{"FR", "US"}) +otherUsers := users.WhereNotIn("Country", []any{"FR"}) +names := users.Pluck("Name") +``` + +## Aggregates + +Collection aggregate helpers calculate values from the items: + +```go +numbers := collect.New(1, 2, 3, 4, 5) + +numbers.Sum(func(n int) float64 { return float64(n) }) // 15 +numbers.Avg(func(n int) float64 { return float64(n) }) // 3 +numbers.Min(func(n int) float64 { return float64(n) }) // 1 +numbers.Max(func(n int) float64 { return float64(n) }) // 5 +``` + +Use `Reduce` when you need to build a single value from the collection: + +```go +total := numbers.Reduce(func(acc any, n int, _ int) any { + return acc.(int) + n +}, 0) +``` + +## Sorting And Slicing + +Collections include helpers for ordering and slicing data: + +```go +numbers := collect.New(5, 1, 4, 2, 3) + +numbers.Sort(func(a, b int) bool { return a < b }).All() // []int{1, 2, 3, 4, 5} +numbers.Reverse().All() // []int{3, 2, 4, 1, 5} +numbers.Take(3).All() // []int{5, 1, 4} +numbers.Skip(2).All() // []int{4, 2, 3} +numbers.Slice(2).All() // []int{4, 2, 3} +numbers.Chunk(2) // [][]int{{5, 1}, {4, 2}, {3}} +``` + +Use `Splice` when you need to remove or insert items in place. It mutates the current collection and returns the removed items. A negative `deleteCount` is treated as `0`, so it inserts without removing existing items: + +```go +letters := collect.New("a", "b", "c", "d") + +removed := letters.Splice(1, 2, "x") +removed.All() // []string{"b", "c"} +letters.All() // []string{"a", "x", "d"} + +inserted := letters.Splice(1, -1, "y") +inserted.All() // []string{} +letters.All() // []string{"a", "y", "x", "d"} +``` + +For structs, use `SortBy` or `SortByDesc` with a key function: + +```go +sortedUsers := users.SortBy(func(user User) string { + return user.Name +}) +``` + +## Conditional Operations + +Use `When` and `Unless` to conditionally apply transformations while keeping a fluent chain: + +```go +result := collect.New(1, 2, 3, 4, 5). + When(true, func(c *collect.Collection[int]) *collect.Collection[int] { + return c.Filter(func(n int, _ int) bool { return n > 2 }) + }). + Unless(false, func(c *collect.Collection[int]) *collect.Collection[int] { + return c.Take(2) + }) + +result.All() // []int{3, 4} +``` + +Use `Tap` to run side effects without breaking the chain: + +```go +numbers.Tap(func(c *collect.Collection[int]) { + fmt.Println("Processing", c.Count(), "items") +}) +``` + +## Lazy Collections + +Lazy collections are useful for large datasets or generated values because intermediate operations are not executed until a terminal method or helper, such as `All`, `Count`, `First`, `Collect`, `Sum`, or `LazyReduce`, is called. + +Create lazy collections from slices, ranges, generators, functions, repeated values, or channels: + +```go +lazy := collect.LazyOf([]int{1, 2, 3, 4, 5}) +lazy = collect.LazyNew(1, 2, 3, 4, 5) + +rangeItems := collect.LazyRange(1, 1000000) +generated := collect.LazyGenerate(func(i int) int { + return i * 2 +}, 100) +repeated := collect.LazyRepeat("Goravel", 3) +``` + +Chain lazy operations and consume only the items you need: + +```go +result := collect.LazyRange(1, 1000000). + Filter(func(n int, _ int) bool { return n%100 == 0 }). + Take(5). + All() + +result // []int{100, 200, 300, 400, 500} +``` + +Lazy collections handle early-exit operations safely for finite channel producers. Methods such as `First`, `FirstWhere`, `Contains`, `Every`, `IsEmpty`, `Take`, and `TakeWhile` drain remaining input when they stop early. + +Use `Collect` to convert a lazy collection into an eager collection: + +```go +eager := collect.LazyRange(1, 10).Collect() +``` + +When mapping a lazy collection to another concrete type, use the generic `LazyMap` helper: + +```go +numbers := collect.LazyOf([]string{"1", "2", "3"}) + +ints := collect.LazyMap(numbers, func(value string, _ int) int { + n, _ := strconv.Atoi(value) + return n +}) + +ints.All() // []int{1, 2, 3} +``` + +## Method Overview + +Common eager collection methods include: + +| Category | Methods | +| --- | --- | +| Create | `New`, `Of` | +| Read | `All`, `Count`, `First`, `Last`, `Get`, `Has`, `Contains`, `ContainsStrict`, `DoesntContain`, `Search`, `SearchBy`, `Random` | +| Transform | `Map`, `FlatMap`, `MapInto`, `MapSpread`, `Transform`, `Reverse`, `Shuffle`, `Concat`, `Collapse`, `Unique`, `UniqueBy`, `Duplicates` | +| Filter | `Filter`, `Reject`, `Where`, `WhereIn`, `WhereNotIn`, `WhereNull`, `WhereNotNull` | +| Aggregate | `Sum`, `Avg`, `Min`, `Max`, `Median`, `Mode`, `Reduce`, `CountBy` | +| Slice | `Chunk`, `Slice`, `Take`, `Skip`, `Split`, `ForPage`, `Splice` | +| Utility | `Clone`, `Tap`, `Pipe`, `ToJson`, `Join`, `KeyBy`, `Pluck`, `Zip` | + +Common lazy collection methods include: + +| Category | Methods | +| --- | --- | +| Create | `LazyNew`, `LazyOf`, `LazyRange`, `LazyGenerate`, `LazyFromFunc`, `LazyFromChannel`, `LazyRepeat` | +| Pipeline | `Filter`, `Reject`, `Map`, `FlatMap`, `Where`, `WhereIn`, `WhereNotIn`, `Take`, `Skip`, `TakeWhile`, `SkipWhile`, `Unique`, `UniqueBy`, `Sort` | +| Terminal | `All`, `Count`, `First`, `FirstWhere`, `Last`, `Contains`, `Every`, `IsEmpty`, `IsNotEmpty`, `Collect`, `Sum`, `Avg`, `Min`, `Max`, `ToJson`, `Iterator` | +| Generic | `LazyMap`, `LazyReduce` | diff --git a/en/digging-deeper/helpers.md b/en/digging-deeper/helpers.md index edffc28ff..cccbd5f7a 100644 --- a/en/digging-deeper/helpers.md +++ b/en/digging-deeper/helpers.md @@ -599,6 +599,8 @@ convert.Pointer(1) // *int(1) import "github.com/goravel/framework/support/collect" ``` +The helpers below operate directly on slices or maps. For fluent eager and lazy collections, see [Collections](collections.md). + ### `collect.Count()` The `collect.Count()` function returns the number of items in the given collection: diff --git a/en/digging-deeper/package-development.md b/en/digging-deeper/package-development.md index a7d450417..bd39a11a9 100644 --- a/en/digging-deeper/package-development.md +++ b/en/digging-deeper/package-development.md @@ -79,6 +79,39 @@ func main() { } ``` +If the package import path is long or its default package name is not convenient, setup modify helpers support an import alias in the `" "` format. Use the same aliased import string when installing and uninstalling: + +```go +func main() { + setup := packages.Setup(os.Args) + + serviceProvider := "&admin.ServiceProvider{}" + moduleImport := "admin " + setup.Paths().Module().Import() + + setup.Install( + modify.RegisterProvider(moduleImport, serviceProvider), + ).Uninstall( + modify.UnregisterProvider(moduleImport, serviceProvider), + ).Execute() +} +``` + +After installation, the generated import uses the alias and the registered item can reference that alias: + +```go +import ( + admin "github.com/example/goravel-admin" +) + +func Providers() []foundation.ServiceProvider { + return []foundation.ServiceProvider{ + &admin.ServiceProvider{}, + } +} +``` + +Alias import strings are also supported by setup helpers that add and remove routes, middleware, commands, jobs, migrations, rules, filters, seeders, and service providers. + ## Resources ### Configuration diff --git a/en/getting-started/compile.md b/en/getting-started/compile.md index 046268496..c05e8ac72 100644 --- a/en/getting-started/compile.md +++ b/en/getting-started/compile.md @@ -21,8 +21,20 @@ The Goravel project can be compiled with the following command: # Specify the output file name ./artisan build --name=goravel ./artisan build -n=goravel + +# Run go generate before building +./artisan build --generate +./artisan build -g +``` + +The `--generate` flag runs `go generate ./...` before the build step. It is disabled by default and can be combined with other build options: + +```shell +./artisan build --generate --os=linux --arch=amd64 ``` +When `--generate` is combined with target options such as `--os` or `--arch`, the generate step uses the same build environment. + ## Manual compilation ### Regular compilation diff --git a/en/getting-started/configuration.md b/en/getting-started/configuration.md index c7cf7c56b..320e8e676 100644 --- a/en/getting-started/configuration.md +++ b/en/getting-started/configuration.md @@ -71,3 +71,48 @@ You can use the `artisan about` command to view the framework version, configura ```bash ./artisan about ``` + +### Maintenance Mode + +You may use the `down` command to put your application into maintenance mode. The command stores maintenance metadata at the `framework/maintenance.json` storage path: + +```shell +./artisan down +``` + +You may provide a custom reason and HTTP status code for the maintenance response: + +```shell +./artisan down --reason="Upgrading database" --status=503 +``` + +The `down` command also supports redirecting users to a path or rendering a view while the application is in maintenance mode: + +```shell +./artisan down --redirect=/maintenance +./artisan down --render=errors/503 +``` + +When using `--render`, the view must already exist. If `--redirect` or `--render` is provided, the `--reason` response body is not used. + +You may allow temporary access to the application by setting a secret. Users can bypass maintenance mode by visiting the application with the matching `secret` query parameter: + +```shell +./artisan down --secret=let-me-in +``` + +```text +https://example.com?secret=let-me-in +``` + +You can also let Goravel generate a random secret for you: + +```shell +./artisan down --with-secret +``` + +To bring the application out of maintenance mode, run the `up` command: + +```shell +./artisan up +``` diff --git a/en/orm/getting-started.md b/en/orm/getting-started.md index c93719378..156b038ab 100644 --- a/en/orm/getting-started.md +++ b/en/orm/getting-started.md @@ -201,6 +201,37 @@ func (r *User) GlobalScopes() map[string]func(contractsorm.Query) contractsorm.Q } ``` +You can access the context passed by `facades.Orm().WithContext(ctx)` from the query inside a global scope: + +```go +import ( + "context" + + contractsorm "github.com/goravel/framework/contracts/database/orm" + "github.com/goravel/framework/database/orm" + "github.com/goravel/framework/facades" +) + +type User struct { + orm.Model + Name string +} + +func (r *User) GlobalScopes() map[string]func(contractsorm.Query) contractsorm.Query { + return map[string]func(contractsorm.Query) contractsorm.Query{ + "tenant": func(query contractsorm.Query) contractsorm.Query { + tenantID, _ := query.Context().Value("tenant_id").(uint) + + return query.Where("tenant_id", tenantID) + }, + } +} + +ctx := context.WithValue(context.Background(), "tenant_id", uint(1)) +var users []User +err := facades.Orm().WithContext(ctx).Query().Find(&users) +``` + If you want to remove global scopes in a query, you can use the `WithoutGlobalScopes` function: ```go @@ -228,6 +259,7 @@ facades.Orm().Query().WithoutGlobalScopes("name").Get(&users) | Avg | [Avg](#Avarage) | | BeginTransaction | [Begin transaction](#transaction) | | Commit | [Commit transaction](#transaction) | +| Context | [Inject Context](#inject-context) | | Count | [Count](#count) | | Create | [Create](#create) | | Cursor | [Cursor](#cursor) | diff --git a/en/security/authentication.md b/en/security/authentication.md index edf9ea084..080bfd127 100644 --- a/en/security/authentication.md +++ b/en/security/authentication.md @@ -111,11 +111,22 @@ err := facades.Auth(ctx).Logout() ```go token, err := facades.Auth(ctx).Guard("admin").LoginUsingID(1) err := facades.Auth(ctx).Guard("admin").Parse(token) -token, err := facades.Auth(ctx).Guard("admin").User(&user) +err := facades.Auth(ctx).Guard("admin").User(&user) ``` > When the default guard is not used, the `Guard` method must be called before calling the above methods. +JWT tokens are bound to the guard that generated them. If you parse a token with a different guard, Goravel returns `auth.ErrorGuardMismatch`: + +```go +token, err := facades.Auth(ctx).Guard("user").LoginUsingID(1) +payload, err := facades.Auth(ctx).Guard("admin").Parse(token) + +if errors.Is(err, auth.ErrorGuardMismatch) { + // The token was issued by another guard. +} +``` + ## Custom Driver ### Add Custom Guard diff --git a/en/the-basics/validation.md b/en/the-basics/validation.md index c6a4a50ea..aa474a75a 100644 --- a/en/the-basics/validation.md +++ b/en/the-basics/validation.md @@ -50,10 +50,10 @@ Now we are ready to fill in our `Store` method with the logic to validate the ne ```go func (r *PostController) Store(ctx http.Context) { - validator, err := ctx.Request().Validate(map[string]string{ - "title": "required|max_len:255", + validator, err := ctx.Request().Validate(map[string]any{ + "title": "required|max:255", "body": "required", - "code": "required|regex:^\d{4,6}$", + "code": "required|regex:^[0-9]{4,6}$", }) } ``` @@ -63,8 +63,8 @@ func (r *PostController) Store(ctx http.Context) { If the incoming HTTP request contains "nested" field data, you may specify these fields in your validation rules using the "dot" syntax: ```go -validator, err := ctx.Request().Validate(map[string]string{ - "title": "required|max_len:255", +validator, err := ctx.Request().Validate(map[string]any{ + "title": "required|max:255", "author.name": "required", "author.description": "required", }) @@ -75,11 +75,22 @@ validator, err := ctx.Request().Validate(map[string]string{ If the incoming HTTP request contains "array" field data, you may specify these fields in your validation rules using the `*` syntax: ```go -validator, err := ctx.Request().Validate(map[string]string{ +validator, err := ctx.Request().Validate(map[string]any{ "tags.*": "required", }) ``` +Wildcard rules preserve the original slice shape in validated data: + +```go +validator, err := facades.Validation().Make(ctx, + map[string]any{"scores": []int{1, 2}}, + map[string]any{"scores.*": "required|integer"}, +) + +scores := validator.Validated()["scores"].([]int) // []int{1, 2} +``` + ## Form Request Validation ### Creating Form Requests @@ -91,7 +102,7 @@ For more complex validation scenarios, you may wish to create a "form request". ./artisan make:request user/StorePostRequest ``` -The generated form request class will be placed in the `app/http/requests` directory. If this directory does not exist, it will be created when you run the `make:request` command. Each form request generated by Goravel has six methods: `Authorize`, `Rules`. In addition, you can customize the `Filters`, `Messages`, `Attributes` and `PrepareForValidation` methods for further operations. +The generated form request class will be placed in the `app/http/requests` directory. If this directory does not exist, it will be created when you run the `make:request` command. Each form request generated by Goravel has `Authorize` and `Rules` methods. You can also customize the optional `Filters`, `Messages`, `Attributes`, and `PrepareForValidation` methods for further operations. The `Authorize` method is responsible for determining if the currently authenticated user can perform the action represented by the request, while the `Rules` method returns the validation rules that should apply to the request's data: @@ -102,7 +113,6 @@ import ( "mime/multipart" "github.com/goravel/framework/contracts/http" - "github.com/goravel/framework/contracts/validation" ) type StorePostRequest struct { @@ -115,10 +125,10 @@ func (r *StorePostRequest) Authorize(ctx http.Context) error { return nil } -func (r *StorePostRequest) Rules(ctx http.Context) map[string]string { - return map[string]string{ +func (r *StorePostRequest) Rules(ctx http.Context) map[string]any { + return map[string]any{ // The keys are consistent with the incoming keys. - "name": "required|max_len:255", + "name": "required|max:255", "file": "required|file", "files": "required|array", "files.*": "required|file", @@ -165,12 +175,13 @@ func (r *StorePostRequest) Authorize(ctx http.Context) error { ### Filter Input Data -You can format the input data by improving the `Filters` method of the form request. This method should return an map of `attribute/filter`: +You can format the input data by improving the `Filters` method of the form request. This method should return a map of `attribute/filter` pairs. Filter values may be strings or `[]string` values: ```go -func (r *StorePostRequest) Filters(ctx http.Context) map[string]string { - return map[string]string{ +func (r *StorePostRequest) Filters(ctx http.Context) map[string]any { + return map[string]any{ "name": "trim", + "age": []string{"trim", "to_int"}, } } ``` @@ -215,7 +226,7 @@ func (r *StorePostRequest) PrepareForValidation(ctx http.Context, data validatio ## Manually Creating Validators -If you do not want to use the `Validate` method on the request, you may create a validator instance manually using the `facades.Validator`. The `Make` method of the facade generates a new validator instance: +If you do not want to use the `Validate` method on the request, you may create a validator instance manually using `facades.Validation()`. The `Make` method of the facade generates a new validator instance: ```go func (r *PostController) Store(ctx http.Context) http.Response { @@ -224,8 +235,8 @@ func (r *PostController) Store(ctx http.Context) http.Response { map[string]any{ "name": "Goravel", }, - map[string]string{ - "title": "required|max_len:255", + map[string]any{ + "title": "required|max:255", "body": "required", }) @@ -239,11 +250,11 @@ func (r *PostController) Store(ctx http.Context) http.Response { } ``` -The first argument passed to the `Make` method is the data under validation which can be `map[string]any` or `struct`. The second argument is an array of validation rules to be applied to the data. +After `ctx`, the data argument passed to the `Make` method can be `map[string]any`, `struct`, `url.Values`, `map[string][]string`, `*http.Request`, or another supported request data source. The next argument is a `map[string]any` of validation rules. Rule values may be strings or `[]string` values. ### Customizing The Error Messages -If needed, you may provide custom error messages that a validator instance should use instead of the default error messages provided by Goravel. You may pass the custom messages as the third argument to the `Make` method (also applicable to `ctx.Request().Validate()`): +If needed, you may provide custom error messages that a validator instance should use instead of the default error messages provided by Goravel. You may pass custom messages with `validation.Messages` (also applicable to `ctx.Request().Validate()`): ```go validator, err := facades.Validation().Make(ctx, input, rules, validation.Messages(map[string]string{ @@ -261,9 +272,11 @@ validator, err := facades.Validation().Make(ctx, input, rules, validation.Messag })) ``` +Explicit message overrides have priority over custom rule defaults. Goravel resolves messages in this order: `field.rule` message, then `rule` message, then the custom rule's `Message()` return value. + ### Specifying Custom Attribute Values -Many of Goravel's built-in error messages include an `:attribute` placeholder that is replaced with the name of the field or attribute under validation. To customize the values used to replace these placeholders for specific fields, you may pass an array of custom attributes as the third argument to the `Make` method (also applicable to `ctx.Request().Validate()`): +Many of Goravel's built-in error messages include an `:attribute` placeholder that is replaced with the name of the field or attribute under validation. To customize the values used to replace these placeholders for specific fields, you may pass custom attributes with `validation.Attributes` (also applicable to `ctx.Request().Validate()`): ```go validator, err := facades.Validation().Make(ctx, input, rules, validation.Attributes(map[string]string{ @@ -273,17 +286,19 @@ validator, err := facades.Validation().Make(ctx, input, rules, validation.Attrib ### Format Data Before Validation -You can format the data before validating the data for more flexible data validation, and you can pass the method of formatting the data as the third parameter to the `Make` method (also applicable to `ctx.Request().Validate()`): +You can format the data before validating the data for more flexible data validation, and you can pass the formatting callback with `validation.PrepareForValidation` (also applicable to `ctx.Request().Validate()`): ```go import ( + "context" + validationcontract "github.com/goravel/framework/contracts/validation" "github.com/goravel/framework/validation" ) func (r *PostController) Store(ctx http.Context) http.Response { validator, err := facades.Validation().Make(ctx, input, rules, - validation.PrepareForValidation(func(ctx http.Context, data validationcontract.Data) error { + validation.PrepareForValidation(func(ctx context.Context, data validationcontract.Data) error { if name, exist := data.Get("name"); exist { return data.Set("name", name) } @@ -297,9 +312,21 @@ func (r *PostController) Store(ctx http.Context) http.Response { ## Working With Validated Input -After validating incoming request data using form requests or manually created validator instances, you still want to bind the request data to a `struct`, there are two ways to do this: +After validating incoming request data using form requests or manually created validator instances, you can retrieve the validated data or bind the request data to a `struct`. + +1. After validation passes, use the `Validated` method to retrieve only fields covered by validation rules. Excluded fields are omitted, and wildcard slice rules keep the original slice shape: + +```go +rules := map[string]any{ + "name": "required|string", + "scores.*": "required|integer", +} + +validator, err := ctx.Request().Validate(rules) +validated := validator.Validated() +``` -1. Use the `Bind` method, this will bind all incoming data, including unvalidated data: +2. Use the `Bind` method to bind data to a struct after validation succeeds. `Bind` merges validated data back over the original request data, so fields without rules can still be bound: ```go validator, err := ctx.Request().Validate(rules) @@ -311,7 +338,7 @@ var user models.User err := validator.Bind(&user) ``` -2. The incoming data is automatically bound to the form when you use request for validation: +3. The incoming data is automatically bound to the form request when you use request validation: ```go var storePost requests.StorePostRequest @@ -321,7 +348,7 @@ fmt.Println(storePost.Name) ## Working With Error Messages -### Retrieving one Error Message For A Field (Random) +### Retrieving One Error Message For A Field ```go validator, err := ctx.Request().Validate(rules) @@ -352,79 +379,62 @@ if validator.Errors().Has("email") { ## Available Validation Rules -Below is a list of all available validation rules and their function: - -| Name | Description | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `required` | Check value is required and cannot be zero value. For example, field type is `bool`, the passing value is `false`, it can not pass the validation. | -| `required_if` | `required_if:anotherfield,value,...` The field under validation must be present and not empty if the anotherField field is equal to any value. | -| `required_unless` | `required_unless:anotherfield,value,...` The field under validation must be present and not empty unless the anotherField field is equal to any value. | -| `required_with` | `required_with:foo,bar,...` The field under validation must be present and not empty only if any of the other specified fields are present. | -| `required_with_all` | `required_with_all:foo,bar,...` The field under validation must be present and not empty only if all of the other specified fields are present. | -| `required_without` | `required_without:foo,bar,...` The field under validation must be present and not empty only when any of the other specified fields are not present. | -| `required_without_all` | `required_without_all:foo,bar,...` The field under validation must be present and not empty only when all of the other specified fields are not present. | -| `int` | Check value is `intX` `uintX` type, and support size checking. eg: `int` `int:2` `int:2,12`. Notice: [Points for using rules](#int) | -| `uint` | Check value is `uint(uintX)` type, `value >= 0` | -| `bool` | Check value is bool string(`true`: "1", "on", "yes", "true", `false`: "0", "off", "no", "false"). | -| `string` | Check value is string type, and support size checking. eg:`string` `string:2` `string:2,12` | -| `float` | Check value is `float(floatX)` type | -| `slice` | Check value is slice type(`[]intX` `[]uintX` `[]byte` `[]string`) | -| `in` | `in:foo,bar,…` Check if the value is in the given enumeration | -| `not_in` | `not_in:foo,bar,…` Check if the value is not in the given enumeration | -| `starts_with` | `starts_with:foo` Check if the input string value is starts with the given sub-string | -| `ends_with` | `ends_with:foo` Check if the input string value is ends with the given sub-string | -| `between` | `between:min,max` Check that the value is a number and is within the given range | -| `max` | `max:value` Check value is less than or equal to the given value(`intX` `uintX` `floatX`) | -| `min` | `min:value` Check value is greater than or equal to the given value(`intX` `uintX` `floatX`) | -| `eq` | `eq:value` Check that the input value is equal to the given value | -| `ne` | `ne:value` Check that the input value is not equal to the given value | -| `lt` | `lt:value` Check value is less than the given value(`intX` `uintX` `floatX`) | -| `gt` | `gt:value` Check value is greater than the given value(`intX` `uintX` `floatX`) | -| `len` | `len:value` Check value length is equals to the given size(`string` `array` `slice` `map`) | -| `min_len` | `min_len:value` Check the minimum length of the value is the given size(`string` `array` `slice` `map`) | -| `max_len` | `max_len:value` Check the maximum length of the value is the given size(`string` `array` `slice` `map`) | -| `email` | Check value is email address string | -| `array` | Check value is array, slice type | -| `map` | Check value is a MAP type | -| `eq_field` | `eq_field:field` Check that the field value is equals to the value of another field | -| `ne_field` | `ne_field:field` Check that the field value is not equals to the value of another field | -| `gt_field` | `gt_field:field` Check that the field value is greater than the value of another field | -| `gte_field` | `gte_field:field` Check that the field value is greater than or equal to the value of another field | -| `lt_field` | `lt_field:field` Check that the field value is less than the value of another field | -| `lte_field` | `lte_field:field` Check if the field value is less than or equal to the value of another field | -| `file` | Verify if it is an uploaded file | -| `image` | Check if it is an uploaded image file and support suffix check | -| `date` | Check the field value is date string | -| `gt_date` | `gt_date:value` Check that the input value is greater than the given date string | -| `lt_date` | `lt_date:value` Check that the input value is less than the given date string | -| `gte_date` | `gte_date:value` Check that the input value is greater than or equal to the given date string | -| `lte_date` | `lte_date:value` Check that the input value is less than or equal to the given date string | -| `alpha` | Verify that the value contains only alphabetic characters | -| `alpha_num` | Check that only letters, numbers are included | -| `alpha_dash` | Check to include only letters, numbers, dashes ( - ), and underscores ( \_ ) | -| `json` | Check value is JSON string | -| `number` | Check value is number string `>= 0` | -| `full_url` | Check value is full URL string(must start with http,https) | -| `ip` | Check value is IP(v4 or v6) string | -| `ipv4` | Check value is IPv4 string | -| `ipv6` | Check value is IPv6 string | -| `regex` | Check if the value can pass the regular verification | -| `uuid` | Check value is UUID string | -| `uuid3` | Check value is UUID3 string | -| `uuid4` | Check value is UUID4 string | -| `uuid5` | Check value is UUID5 string | - -### Points For Using Rules - -#### int - -When using `ctx.Request().Validate(rules)` for validation, the incoming `int` type data will be parsed by `json.Unmarshal` into `float64` type, which will cause the int rule validation to fail. - -**Solutions** - -Option 1: Add [`validation.PrepareForValidation`](#Format-Data-Before-Validation), format the data before validating the data; - -Option 2: Use `facades.Validation().Make()` for rule validation; +Validation rule names now use snake_case by default. Rules and filters are defined with `map[string]any`; each value must be either a pipe-separated `string` or a `[]string` of rule names. + +```go +rules := map[string]any{ + "title": "required|string|max:255", + "slug": []string{"required", "regex:^(news|docs)-[a-z]+$", "string"}, +} +``` + +Use `[]string` when a `regex` or `not_regex` pattern contains `|` and you need to add more rules after it. + +| Category | Rules | +| --- | --- | +| Required | `required`, `required_if`, `required_unless`, `required_with`, `required_with_all`, `required_without`, `required_without_all`, `required_if_accepted`, `required_if_declined` | +| Presence | `filled`, `present`, `present_if`, `present_unless`, `present_with`, `present_with_all`, `missing`, `missing_if`, `missing_unless`, `missing_with`, `missing_with_all` | +| Accepted / Declined | `accepted`, `accepted_if`, `declined`, `declined_if` | +| Prohibited | `prohibited`, `prohibited_if`, `prohibited_unless`, `prohibited_if_accepted`, `prohibited_if_declined`, `prohibits` | +| Type | `string`, `integer`, `int`, `uint`, `numeric`, `boolean`, `bool`, `float`, `array`, `list`, `slice`, `map` | +| Size | `size`, `min`, `max`, `between`, `gt`, `gte`, `lt`, `lte` | +| Numeric | `digits`, `digits_between`, `decimal`, `multiple_of`, `min_digits`, `max_digits` | +| String Format | `alpha`, `alpha_num`, `alpha_dash`, `ascii`, `email`, `url`, `active_url`, `ip`, `ipv4`, `ipv6`, `mac_address`, `mac`, `json`, `uuid`, `uuid3`, `uuid4`, `uuid5`, `ulid`, `hex_color`, `regex`, `not_regex`, `lowercase`, `uppercase` | +| String Content | `starts_with`, `doesnt_start_with`, `ends_with`, `doesnt_end_with`, `contains`, `doesnt_contain`, `confirmed` | +| Comparison | `same`, `different`, `eq`, `ne`, `in`, `not_in`, `in_array`, `in_array_keys` | +| Date | `date`, `date_format`, `date_equals`, `before`, `before_or_equal`, `after`, `after_or_equal`, `timezone` | +| Exclusion | `exclude`, `exclude_if`, `exclude_unless`, `exclude_with`, `exclude_without` | +| File | `file`, `image`, `mimes`, `mimetypes`, `extensions`, `dimensions`, `encoding` | +| Control | `bail`, `nullable`, `sometimes` | +| Array / Database | `distinct`, `required_array_keys`, `exists`, `unique` | + +The `size`, `min`, `max`, `between`, `gt`, `gte`, `lt`, and `lte` rules are type-aware. They compare numeric values for numeric fields, string length for strings, element count for arrays, slices, and maps, and file size for files. + +The `exists` rule uses `exists:table,column1,column2,...`. The `unique` rule uses `unique:table,column,idColumn,except1,except2,...`. Both rules support `connection.table` syntax for the table parameter. + +The `active_url` rule performs a DNS lookup for the URL host. Use it carefully on request hot paths because DNS resolution can add latency. + +### Deprecated Rule Aliases + +The following aliases remain backward-compatible, but will be removed in the next major version. Prefer the new snake_case names: + +| Deprecated | Use Instead | +| --- | --- | +| `len` | `size` | +| `min_len` | `min` | +| `max_len` | `max` | +| `eq_field` | `same` | +| `ne_field` | `different` | +| `gt_field` | `gt` | +| `gte_field` | `gte` | +| `lt_field` | `lt` | +| `lte_field` | `lte` | +| `gt_date` | `after` | +| `lt_date` | `before` | +| `gte_date` | `after_or_equal` | +| `lte_date` | `before_or_equal` | +| `number` | `numeric` | +| `full_url` | `url` | ## Custom Validation Rules @@ -447,6 +457,7 @@ After creating the rule, we need to define its behavior. A rule object has two m package rules import ( + "context" "strings" "github.com/goravel/framework/contracts/validation" @@ -486,37 +497,63 @@ func Boot() contractsfoundation.Application { ## Available Validation Filters -| Name | Description | -| ------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `int/toInt` | Convert value(string/intX/floatX) to `int` type `v.FilterRule("id", "int")` | -| `uint/toUint` | Convert value(string/intX/floatX) to `uint` type `v.FilterRule("id", "uint")` | -| `int64/toInt64` | Convert value(string/intX/floatX) to `int64` type `v.FilterRule("id", "int64")` | -| `float/toFloat` | Convert value(string/intX/floatX) to `float` type | -| `bool/toBool` | Convert string value to bool. (`true`: "1", "on", "yes", "true", `false`: "0", "off", "no", "false") | -| `trim/trimSpace` | Clean up whitespace characters on both sides of the string | -| `ltrim/trimLeft` | Clean up whitespace characters on left sides of the string | -| `rtrim/trimRight` | Clean up whitespace characters on right sides of the string | -| `int/integer` | Convert value(string/intX/floatX) to `int` type `v.FilterRule("id", "int")` | -| `lower/lowercase` | Convert string to lowercase | -| `upper/uppercase` | Convert string to uppercase | -| `lcFirst/lowerFirst` | Convert the first character of a string to lowercase | -| `ucFirst/upperFirst` | Convert the first character of a string to uppercase | -| `ucWord/upperWord` | Convert the first character of each word to uppercase | -| `camel/camelCase` | Convert string to camel naming style | -| `snake/snakeCase` | Convert string to snake naming style | -| `escapeJs/escapeJS` | Escape JS string. | -| `escapeHtml/escapeHTML` | Escape HTML string. | -| `str2ints/strToInts` | Convert string to int slice `[]int` | -| `str2time/strToTime` | Convert date string to `time.Time`. | -| `str2arr/str2array/strToArray` | Convert string to string slice `[]string` | - -## Custom filter +Validation filters use snake_case names by default. Filters run before validation and can be declared on form requests or with `validation.Filters` when manually creating validators. + +| Category | Filters | +| --- | --- | +| String Cleaning | `trim`, `ltrim`, `rtrim` | +| Case Conversion | `lower`, `upper`, `title`, `ucfirst`, `lcfirst` | +| Naming Style | `camel`, `snake` | +| Type Conversion | `to_int`, `to_int64`, `to_uint`, `to_float`, `to_bool`, `to_string`, `to_time` | +| Short Type Aliases | `int`, `int64`, `uint`, `float`, `bool` | +| Escaping / Encoding | `strip_tags`, `escape_js`, `escape_html`, `url_encode`, `url_decode` | +| String Splitting | `str_to_ints`, `str_to_array`, `str_to_time` | + +```go +validator, err := facades.Validation().Make(ctx, input, rules, validation.Filters(map[string]any{ + "name": "trim", + "age": []string{"trim", "to_int"}, +})) +``` + +### Deprecated Filter Aliases + +The following aliases remain backward-compatible, but will be removed in the next major version. Prefer the new snake_case names: + +| Deprecated | Use Instead | +| --- | --- | +| `trimSpace` | `trim` | +| `trimLeft` | `ltrim` | +| `trimRight` | `rtrim` | +| `lowercase` | `lower` | +| `uppercase` | `upper` | +| `lcFirst`, `lowerFirst` | `lcfirst` | +| `ucFirst`, `upperFirst` | `ucfirst` | +| `ucWord`, `upperWord` | `title` | +| `camelCase` | `camel` | +| `snakeCase` | `snake` | +| `toInt`, `integer` | `to_int` | +| `toUint` | `to_uint` | +| `toInt64` | `to_int64` | +| `toFloat` | `to_float` | +| `toBool` | `to_bool` | +| `toString` | `to_string` | +| `toTime`, `str2time`, `strToTime` | `to_time` or `str_to_time` | +| `escapeJs`, `escapeJS` | `escape_js` | +| `escapeHtml`, `escapeHTML` | `escape_html` | +| `urlEncode` | `url_encode` | +| `urlDecode` | `url_decode` | +| `stripTags` | `strip_tags` | +| `str2ints`, `strToInts` | `str_to_ints` | +| `str2arr`, `str2array`, `strToArray` | `str_to_array` | + +## Custom Filters Goravel provides a variety of helpful filters, however, you may wish to specify some of your own. ### Creating Custom Filters -To generate a new rule object, you can simply use the `make:filter` Artisan command. Let's use this command to generate a rule that converts a string to an integer. This rule is already built into the framework, we just create it as an example. Goravel will save this new filter in the `app/filters` directory. If this directory does not exist, Goravel will create it when you run the Artisan command to create the rule: +To generate a new filter object, you can simply use the `make:filter` Artisan command. Let's use this command to generate a filter that converts a string to an integer. This filter is already built into the framework, we just create it as an example. Goravel will save this new filter in the `app/filters` directory. If this directory does not exist, Goravel will create it when you run the Artisan command to create the filter: ```go ./artisan make:filter ToInt @@ -531,10 +568,9 @@ One filter contains two methods: `Signature` and `Handle`. The `Signature` metho package filters import ( - "strings" + "context" "github.com/spf13/cast" - "github.com/goravel/framework/contracts/validation" ) type ToInt struct { @@ -542,20 +578,20 @@ type ToInt struct { // Signature The signature of the filter. func (receiver *ToInt) Signature() string { - return "ToInt" + return "to_int_custom" } // Handle defines the filter function to apply. func (receiver *ToInt) Handle(ctx context.Context) any { return func (val any) int { - return cast.ToString(val) + return cast.ToInt(val) } } ``` ### Register Custom Filters -A new rule created by `make:filter` will be registered automatically in the `bootstrap/filters.go::Filters()` function and the function will be called by `WithFilters`. You need register the rule manually if you create the rule file by yourself. +A new filter created by `make:filter` will be registered automatically in the `bootstrap/filters.go::Filters()` function and the function will be called by `WithFilters`. You need register the filter manually if you create the filter file by yourself. ```go func Boot() contractsfoundation.Application { diff --git a/en/upgrade/history.md b/en/upgrade/history.md index 920e4b67f..6138c1c5e 100644 --- a/en/upgrade/history.md +++ b/en/upgrade/history.md @@ -1,5 +1,6 @@ # History Upgrade +- [Upgrading To v1.16 From v1.15](v1.16.md) - [Upgrading To v1.15 From v1.14](v1.15.md) - [Upgrading To v1.14 From v1.13](v1.14.md) - [Upgrading To v1.13 From v1.12](v1.13.md) diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md new file mode 100644 index 000000000..3962dbab5 --- /dev/null +++ b/en/upgrade/v1.18.md @@ -0,0 +1,167 @@ +# Upgrading To v1.18 From v1.17 + +## Enhancements 🚀 + +- [Laravel-style collections](#laravel-style-collections) +- [Native validation engine and built-in rules](#native-validation-engine-and-built-in-rules) +- [Maintenance mode commands](#maintenance-mode-commands) +- [Build command supports go generate](#build-command-supports-go-generate) +- [Orm query exposes context](#orm-query-exposes-context) +- [Package setup supports import aliases](#package-setup-supports-import-aliases) +- [Artisan console supports table output](#artisan-console-supports-table-output) + +## Upgrade Guide + +### 1. Update dependencies + +```shell +go get github.com/goravel/framework@latest +go mod tidy + +// If using installer +goravel upgrade +``` + +### 2. Update validation rule and filter maps + +Validation rules and filters now use `map[string]any` instead of `map[string]string`. Update request validation calls, manual validator calls, and form request `Rules` / `Filters` methods: + +```diff +-ctx.Request().Validate(map[string]string{ ++ctx.Request().Validate(map[string]any{ + "title": "required|max:255", + }) +``` + +Rule and filter values may be either pipe-separated strings or `[]string`. Prefer `[]string` when a `regex` or `not_regex` pattern contains `|` and must be followed by more rules: + +```go +rules := map[string]any{ + "slug": []string{"required", "regex:^(news|docs)-[a-z]+$", "string"}, +} +``` + +## Feature Introduction + +### Native validation engine and built-in rules + +Goravel now uses a native validation engine instead of the previous external validation dependency. The new engine supports dot notation, wildcard validation, validated data retrieval, custom filters, explicit message priority, and a broader Laravel-style built-in rule set. + +```go +validator, err := facades.Validation().Make(ctx, + map[string]any{ + "email": " User@Goravel.dev ", + "scores": []int{1, 2}, + }, + map[string]any{ + "email": "required|email", + "scores.*": "required|integer", + }, + validation.Filters(map[string]any{ + "email": "trim|lower", + }), +) + +validated := validator.Validated() +scores := validated["scores"].([]int) +``` + +[View Document](../the-basics/validation.md) + +### Laravel-style collections + +Goravel now provides fluent eager and lazy collection utilities in the `support/collect` package. You can create collections from slices, chain filtering and mapping operations, use Laravel-style `Where` comparisons for structs, aggregate values, and process large datasets with `LazyCollection`. + +```go +numbers := collect.New(1, 2, 3, 4, 5, 6) + +evens := numbers.Filter(func(n int, _ int) bool { + return n%2 == 0 +}) + +evens.All() // []int{2, 4, 6} +``` + +[View Document](../digging-deeper/collections.md) + +### Maintenance mode commands + +Goravel now provides the `artisan down` and `artisan up` commands for application maintenance mode. The `down` command writes maintenance metadata to storage, and the `up` command removes it. + +```shell +./artisan down +./artisan up +``` + +The `down` command supports response customization with options such as `--reason`, `--status`, `--redirect`, `--render`, `--secret`, and `--with-secret`. + +```shell +./artisan down --reason="Upgrading database" --status=503 +./artisan down --redirect=/maintenance +./artisan down --render=errors/503 +./artisan down --with-secret +``` + +[View Document](../getting-started/configuration.md#maintenance-mode) + +### Build command supports go generate + +The `build` command now supports the `--generate` flag and `-g` alias. When enabled, Goravel runs `go generate ./...` before compiling the binary, making it easier to include generated code in the build workflow without changing the default behavior. + +```shell +./artisan build --generate +./artisan build -g +``` + +[View Document](../getting-started/compile.md#compile-command) + +### Orm query exposes context + +Orm queries now provide a `Context()` accessor. Code that receives a `contractsorm.Query`, such as model global scopes, can read the context passed by `facades.Orm().WithContext(ctx)`. + +```go +"tenant": func(query contractsorm.Query) contractsorm.Query { + tenantID, _ := query.Context().Value("tenant_id").(uint) + + return query.Where("tenant_id", tenantID) +} +``` + +[View Document](../orm/getting-started.md#setting-global-scope) + +### Package setup supports import aliases + +Package setup modify helpers now support import aliases in the `" "` format. This keeps generated bootstrap files readable when package import paths are long or when the package should be referenced with a custom name. + +```go +serviceProvider := "&admin.ServiceProvider{}" +moduleImport := "admin " + setup.Paths().Module().Import() + +setup.Install( + modify.RegisterProvider(moduleImport, serviceProvider), +).Uninstall( + modify.UnregisterProvider(moduleImport, serviceProvider), +).Execute() +``` + +[View Document](../digging-deeper/package-development.md#install-the-package) + +### Artisan console supports table output + +Goravel now provides the `ctx.Table` method for rendering structured command output. It accepts table headers, table rows, and an optional `console.TableOption` for border, style, column, width, and height customization. + +```go +func (receiver *ReportCommand) Handle(ctx console.Context) error { + headers := []string{"ID", "Name", "Status"} + rows := [][]string{ + {"1", "Goravel", "Active"}, + {"2", "Framework", "Ready"}, + } + + ctx.Table(headers, rows) + + return nil +} +``` + +[View Document](../digging-deeper/artisan-console.md#tables) From 607ccfc89bcd3c1992f99b171e21683d73b4207f Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 9 Jun 2026 16:33:39 +0800 Subject: [PATCH 02/10] optimize --- en/prologue/contributions.md | 1 - en/the-basics/grpc.md | 69 ++++++++++++++++++++++++++++++++++++ en/the-basics/logging.md | 16 +++++++++ en/upgrade/v1.18.md | 51 ++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/en/prologue/contributions.md b/en/prologue/contributions.md index 210a3f161..86cb76f9f 100644 --- a/en/prologue/contributions.md +++ b/en/prologue/contributions.md @@ -135,7 +135,6 @@ You can find or create an issue in [Issue List](https://github.com/goravel/gorav | [goravel/mysql](https://github.com/goravel/mysql) | The MySQL driver of Database module | | [goravel/sqlserver](https://github.com/goravel/sqlserver) | The SQLServer driver of Database module | | [goravel/sqlite](https://github.com/goravel/sqlite) | The SQLite driver of Database module | -| [goravel/file-rotatelogs](https://github.com/goravel/file-rotatelogs) | Providers log splitting functionality for Log module | | [goravel/.github](https://github.com/goravel/.github) | [Community health file](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/creating-a-default-community-health-file) | ## Code of Conduct diff --git a/en/the-basics/grpc.md b/en/the-basics/grpc.md index a7f996cee..7a79f90ce 100644 --- a/en/the-basics/grpc.md +++ b/en/the-basics/grpc.md @@ -10,6 +10,75 @@ Grpc module can be operated by `facades.Grpc()`. Goravel provides an elegant way In the `config/grpc.go` file, you can configure the Grpc module, where `grpc.host` configures the domain name of the server, and `grpc.servers` configures the servers which the client will connect to. +## Transport Credentials + +By default, gRPC clients use insecure transport credentials and the gRPC server listens without TLS. You can register `credentials.TransportCredentials` during application bootstrap when your services require TLS or mTLS. + +### Server Credentials + +Register server credentials before the gRPC server is created: + +```go +import "google.golang.org/grpc/credentials" + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithGrpcServerCredentials(func() credentials.TransportCredentials { + creds, err := credentials.NewServerTLSFromFile( + "storage/certs/server.crt", + "storage/certs/server.key", + ) + if err != nil { + panic(err) + } + + return creds + }). + Create() +} +``` + +### Client Credentials + +Register client credential groups in `bootstrap/app.go`, then reference the group name in `config/grpc.go` with `grpc.servers..credentials`: + +```go +import "google.golang.org/grpc/credentials" + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithGrpcClientCredentials(func() map[string]credentials.TransportCredentials { + userCreds, err := credentials.NewClientTLSFromFile( + "storage/certs/ca.crt", + "user.example.com", + ) + if err != nil { + panic(err) + } + + return map[string]credentials.TransportCredentials{ + "user": userCreds, + } + }). + Create() +} +``` + +```go +// config/grpc.go +"servers": map[string]any{ + "user": map[string]any{ + "host": config.Env("GRPC_USER_HOST", ""), + "port": config.Env("GRPC_USER_PORT", ""), + "credentials": "user", + "interceptors": []string{}, + "stats_handlers": []string{}, + }, +}, +``` + +If a client has no `credentials` value, Goravel uses insecure credentials for backward compatibility. If the configured group is not registered, Goravel logs a warning and falls back to insecure credentials. For mTLS, return credentials created with `credentials.NewTLS` and a `tls.Config` that includes the required client certificates or trusted CA pools. + ## Controllers Controllers can be defined in the `app/grpc/controllers` directory. diff --git a/en/the-basics/logging.md b/en/the-basics/logging.md index e57224e92..e56ab4b6c 100644 --- a/en/the-basics/logging.md +++ b/en/the-basics/logging.md @@ -29,6 +29,22 @@ The `print` configuration in `single` and `daily` drivers can control log output facades.Log().WithContext(ctx) ``` +When `WithContext` is used, Goravel writes context key-value pairs to the log output. Framework-internal keys such as `GoravelAuthJwt` and `goravel_http_client_name` are excluded automatically to avoid leaking sensitive or noisy values. + +You can exclude additional context keys by adding `context.exclude` to `config/logging.go`: + +```go +// config/logging.go +"context": map[string]any{ + "exclude": []any{ + "access_token", + "secret_key", + }, +}, +``` + +String entries match string context keys and typed string context keys by their displayed name. Comparable non-string entries match the exact context key value, which is useful for struct sentinel keys. Uncomparable entries, such as slices or maps, are ignored. + ## Write log messages ```go diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index 3962dbab5..b838977db 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -6,9 +6,11 @@ - [Native validation engine and built-in rules](#native-validation-engine-and-built-in-rules) - [Maintenance mode commands](#maintenance-mode-commands) - [Build command supports go generate](#build-command-supports-go-generate) +- [gRPC supports TLS and mTLS credentials](#grpc-supports-tls-and-mtls-credentials) - [Orm query exposes context](#orm-query-exposes-context) - [Package setup supports import aliases](#package-setup-supports-import-aliases) - [Artisan console supports table output](#artisan-console-supports-table-output) +- [Log WithContext filters framework context keys out](#log-withcontext-filters-framework-context-keys-out) ## Upgrade Guide @@ -16,6 +18,28 @@ ```shell go get github.com/goravel/framework@latest + +// If using gin +go get github.com/goravel/gin@latest + +// If using fiber +go get github.com/goravel/fiber@latest + +// If using redis +go get github.com/goravel/redis@latest + +// If using S3 +go get github.com/goravel/s3@latest + +// If using Oss +go get github.com/goravel/oss@latest + +// If using Cos +go get github.com/goravel/cos@latest + +// If using Minio +go get github.com/goravel/minio@latest + go mod tidy // If using installer @@ -115,6 +139,27 @@ The `build` command now supports the `--generate` flag and `-g` alias. When enab [View Document](../getting-started/compile.md#compile-command) +### gRPC supports TLS and mTLS credentials + +Goravel now supports registering gRPC server and client transport credentials during application bootstrap. Use `WithGrpcServerCredentials` to enable TLS or mTLS for the server, and `WithGrpcClientCredentials` to register client credential groups that can be referenced by `grpc.servers..credentials`. + +```go +return foundation.Setup(). + WithGrpcClientCredentials(func() map[string]credentials.TransportCredentials { + userCreds, err := credentials.NewClientTLSFromFile("storage/certs/ca.crt", "user.example.com") + if err != nil { + panic(err) + } + + return map[string]credentials.TransportCredentials{ + "user": userCreds, + } + }). + Create() +``` + +[View Document](../the-basics/grpc.md#transport-credentials) + ### Orm query exposes context Orm queries now provide a `Context()` accessor. Code that receives a `contractsorm.Query`, such as model global scopes, can read the context passed by `facades.Orm().WithContext(ctx)`. @@ -165,3 +210,9 @@ func (receiver *ReportCommand) Handle(ctx console.Context) error { ``` [View Document](../digging-deeper/artisan-console.md#tables) + +### Log WithContext filters framework context keys out + +Previously, `facades.Log().WithContext(ctx)` could write framework-internal context keys such as `GoravelAuthJwt` and `goravel_http_client_name` to log output. Goravel now excludes these keys by default, and you can add `logging.context.exclude` for project-specific context keys. + +[View Document](../the-basics/logging.md#inject-context) From 98e621573304677bee31edee42a900cb33260c1a Mon Sep 17 00:00:00 2001 From: Bowen Date: Wed, 10 Jun 2026 16:21:45 +0800 Subject: [PATCH 03/10] optimize --- en/ai/sdk.md | 875 ++++++++++++++++++++++++++++ en/getting-started/configuration.md | 11 +- en/upgrade/v1.18.md | 41 +- 3 files changed, 925 insertions(+), 2 deletions(-) create mode 100644 en/ai/sdk.md diff --git a/en/ai/sdk.md b/en/ai/sdk.md new file mode 100644 index 000000000..bd713e881 --- /dev/null +++ b/en/ai/sdk.md @@ -0,0 +1,875 @@ +# AI SDK + +[[toc]] + +## Introduction + +The AI SDK provides a unified API for interacting with AI providers in Goravel applications. It introduces an `AI` facade, stateful conversations, agent classes, provider/model options, prompt attachments, streaming responses, image generation, audio generation, and transcription. + +The core AI module manages conversations and provider resolution. Provider implementations are installed separately, such as `goravel/openai`, `goravel/anthropic`, and `goravel/gemini`. + +## Installation + +### Install AI Facade + +Install the AI facade and core service provider with the `package:install` command: + +```shell +./artisan package:install ai +``` + +This makes `facades.AI()` available and registers the `make:agent` Artisan command. + +### Install Providers + +Install at least one provider package before prompting an agent: + +```shell +./artisan package:install github.com/goravel/openai +./artisan package:install github.com/goravel/anthropic +./artisan package:install github.com/goravel/gemini +``` + +Each provider package registers its own service provider and updates `config/ai.go` so `ai.providers..via` resolves through the package facade. + +### Provider Configuration + +The provider installers update `config/ai.go` automatically. For example, `goravel/openai` adds an OpenAI provider similar to this: + +```go +package config + +import ( + "github.com/goravel/framework/contracts/ai" + openaifacades "github.com/goravel/openai/facades" + "goravel/app/facades" +) + +func init() { + config := facades.Config() + config.Add("ai", map[string]any{ + "default": "openai", + "providers": map[string]any{ + "openai": map[string]any{ + "key": config.Env("OPENAI_API_KEY", ""), + "url": config.Env("OPENAI_BASE_URL", ""), + "via": func() (ai.Provider, error) { + return openaifacades.OpenAI("openai") + }, + "models": map[string]any{ + "text": map[string]any{ + "default": "", + "max_tokens": 0, + }, + "audio": map[string]any{ + "default": "", + }, + "transcription": map[string]any{ + "default": "", + }, + "image": map[string]any{ + "default": "", + }, + }, + }, + }, + }) +} +``` + +Then add the provider credentials to `.env`: + +```ini +OPENAI_API_KEY= +OPENAI_BASE_URL= +``` + +`OPENAI_BASE_URL` is optional. Use it when routing requests through a proxy or an OpenAI-compatible endpoint. If a model default is empty, the provider package uses its own default model. Set `models.text.max_tokens` to limit generated text tokens; leave it as `0` to use the provider default. + +## Creating Agents + +Agents define the system instructions and any initial conversation context that should be sent to the provider. + +You can generate an agent with Artisan: + +```shell +./artisan make:agent SupportAgent +./artisan make:agent user/SupportAgent +``` + +The generated file is placed under `app/agents` and contains the required methods: + +```go +package agents + +import "github.com/goravel/framework/contracts/ai" + +type SupportAgent struct { +} + +func (r *SupportAgent) Instructions() string { + return "You are a concise support assistant for a Goravel application." +} + +func (r *SupportAgent) Messages() []ai.Message { + return []ai.Message{ + {Role: ai.RoleAssistant, Content: "Ask clarifying questions when needed."}, + } +} + +func (r *SupportAgent) Middleware() []ai.Middleware { + return nil +} + +func (r *SupportAgent) Tools() []ai.Tool { + return nil +} +``` + +`Instructions` becomes the system prompt. `Messages` returns the initial conversation history copied into each new conversation. `Middleware` returns the default prompt middleware applied to each new conversation. `Tools` returns the callable tools the model may invoke. + +## Prompting + +Use `facades.AI().Agent` to create a conversation for an agent, then call `Prompt`: + +```go +conversation, err := facades.AI().Agent(&agents.SupportAgent{}) +if err != nil { + return err +} + +response, err := conversation.Prompt("How do I create a controller?") +if err != nil { + return err +} + +fmt.Println(response.Text()) +``` + +The response exposes generated text, usage metadata, and any tool calls returned by the provider: + +```go +text := response.Text() +usage := response.Usage() +toolCalls := response.ToolCalls() + +fmt.Println(text) +fmt.Println(usage.Input(), usage.Output(), usage.Total()) +fmt.Println(toolCalls) +``` + +Use `Then` to run a callback after a response is resolved: + +```go +import ( + "github.com/goravel/framework/contracts/ai" + "goravel/app/facades" +) + +response.Then(func(response ai.AgentResponse) { + facades.Log().Info(response.Text()) +}) +``` + +You can override the configured provider or model for a single conversation: + +```go +conversation, err := facades.AI().Agent( + &agents.SupportAgent{}, + frameworkai.WithProvider("openai"), + frameworkai.WithModel("gpt-5.4"), +) +``` + +If the request should use a specific Go context, call `WithContext` before creating the conversation: + +```go +conversation, err := facades.AI().WithContext(ctx).Agent(&agents.SupportAgent{}) +``` + +## Conversation History + +A conversation stores runtime messages in memory. After a successful `Prompt`, Goravel appends the user input and assistant response to the conversation history: + +```go +conversation, err := facades.AI().Agent(&agents.SupportAgent{}) +if err != nil { + return err +} + +_, err = conversation.Prompt("Hello") +if err != nil { + return err +} + +messages := conversation.Messages() +``` + +Use `Reset` to discard runtime messages and restore the initial messages returned by the agent: + +```go +conversation.Reset() +``` + +## Attachments + +Attachments let you send request-scoped documents and images with a single `Prompt` or `Stream` call. Goravel resolves attachments lazily from common sources and does not persist the binary content in conversation history. + +The attachment examples use these imports: + +```go +import ( + "fmt" + + frameworkai "github.com/goravel/framework/ai" + "github.com/goravel/framework/ai/document" + "github.com/goravel/framework/ai/image" +) +``` + +Attach files with `WithAttachments`: + +```go +response, err := conversation.Prompt("Summarize these files", frameworkai.WithAttachments( + document.FromPath("storage/app/reports/quarterly.pdf"), + image.FromPath("storage/app/charts/revenue.png", image.WithMimeType("image/png")), +)) +if err != nil { + return err +} + +fmt.Println(response.Text()) +``` + +The helper packages are available from `github.com/goravel/framework/ai/document` and `github.com/goravel/framework/ai/image`. The same constructors are also available from the root `github.com/goravel/framework/ai` package as `DocumentFromPath`, `ImageFromPath`, and related helpers. + +Supported attachment sources: + +| Source | Document Helper | Image Helper | +| --- | --- | --- | +| Bytes | `document.FromByte` | `image.FromByte` | +| String | `document.FromString` | - | +| Base64 | `document.FromBase64` | `image.FromBase64` | +| Reader | `document.FromReader` | `image.FromReader` | +| Local path | `document.FromPath` | `image.FromPath` | +| Storage | `document.FromStorage` | `image.FromStorage` | +| URL | `document.FromURL` | `image.FromURL` | +| Uploaded file | `document.FromUpload` | `image.FromUpload` | +| Provider file ID | `document.FromID` | `image.FromID` | + +Use `WithMimeType` to override the detected MIME type. Use `WithDisk` with `FromStorage` when the attachment should be read from a non-default filesystem disk: + +```go +attachment := document.FromStorage( + "reports/monthly.pdf", + document.WithDisk("s3"), + document.WithMimeType("application/pdf"), +) +``` + +`Stream` accepts the same attachment option: + +```go +stream, err := conversation.Stream("Describe this chart", frameworkai.WithAttachments( + image.FromPath("storage/app/charts/revenue.png"), +)) +``` + +### Uploading Attachments + +If a provider supports file uploads, call `Put` on an attachment to upload it and receive a provider-managed file handle: + +```go +file, err := document.FromPath("storage/app/reports/quarterly.pdf").Put( + ctx, + frameworkai.WithProvider("openai"), +) +if err != nil { + return err +} + +fmt.Println(file.ID()) +``` + +Providers that do not support uploads return an explicit error. The OpenAI provider supports uploads and uses the Responses API for prompts, streaming, tool calling, and attachments. + +You may attach a provider-managed file by ID without uploading it again: + +```go +file := document.FromID("file-abc123") + +response, err := conversation.Prompt("Summarize this file", frameworkai.WithAttachments(file)) +if err != nil { + return err +} +``` + +Use `Get` to resolve file metadata or content from the provider, and `Delete` to remove the provider-managed file: + +```go +file := document.FromID("file-abc123") + +resolved, err := file.Get(ctx, frameworkai.WithProvider("openai")) +if err != nil { + return err +} + +content, err := resolved.Content(ctx) +if err != nil { + return err +} + +fmt.Println(resolved.ID(), resolved.MimeType(), len(content)) + +err = file.Delete(ctx, frameworkai.WithProvider("openai")) +``` + +Use `image.FromID` for provider-managed image files. + +## Image Generation + +Use `facades.AI().Image` to generate images from a prompt: + +```go +import ( + "fmt" + + frameworkai "github.com/goravel/framework/ai" + "goravel/app/facades" +) + +response, err := facades.AI().Image("A friendly gopher writing Goravel docs"). + Square(). + Quality(frameworkai.ImageQualityHigh). + Generate() +if err != nil { + return err +} + +content, err := response.Content() +if err != nil { + return err +} + +fmt.Println(response.MimeType(), len(content)) +``` + +You may set the provider and model on the fluent image request: + +```go +response, err := facades.AI().Image("A launch banner for Goravel v1.18"). + Provider("openai"). + Model("gpt-image-2"). + Landscape(). + Generate() +``` + +Use image attachments when editing existing images: + +```go +import ( + frameworkai "github.com/goravel/framework/ai" + "github.com/goravel/framework/ai/image" + "goravel/app/facades" +) + +response, err := facades.AI().Image("Turn this chart into a watercolor illustration"). + Attachments(image.FromPath("storage/app/charts/revenue.png", image.WithMimeType("image/png"))). + Quality(frameworkai.ImageQualityMedium). + Generate() +``` + +The generated image response exposes bytes, MIME type, usage metadata, storage helpers, and a `Then` callback: + +```go +import ( + "github.com/goravel/framework/contracts/ai" + "goravel/app/facades" +) + +response.Then(func(response ai.ImageResponse) { + facades.Log().Info(response.MimeType()) +}) +``` + +Store generated images directly on the configured filesystem: + +```go +path, err := response.Store("public") +if err != nil { + return err +} + +path, err = response.StoreAs("images/gopher.png", "public") +``` + +You may also generate and store in one step: + +```go +path, err := facades.AI().Image("A Goravel mascot"). + Portrait(). + StoreAs("images/mascot.png", "public") +``` + +## Audio Generation + +Use `facades.AI().Audio` to generate speech from text: + +```go +import ( + "fmt" + "time" + + "goravel/app/facades" +) + +response, err := facades.AI().Audio("Welcome to Goravel"). + Provider("openai"). + Model("gpt-4o-mini-tts"). + Male(). + Instructions("Speak slowly and warmly."). + Timeout(30 * time.Second). + Generate() +if err != nil { + return err +} + +content, err := response.Content() +if err != nil { + return err +} + +fmt.Println(response.MimeType(), len(content)) +``` + +Use `Female` for the default female voice, `Male` for the default male voice, or `Voice` for a provider-specific voice: + +```go +response, err := facades.AI().Audio("Your report is ready."). + Voice("alloy"). + Generate() +``` + +Audio responses expose bytes, MIME type, usage metadata, storage helpers, and a `Then` callback: + +```go +import ( + "github.com/goravel/framework/contracts/ai" + "goravel/app/facades" +) + +response.Then(func(response ai.AudioResponse) { + facades.Log().Info(response.MimeType()) +}) +``` + +Store generated audio directly on the configured filesystem: + +```go +path, err := response.Store("public") +if err != nil { + return err +} + +path, err = response.StoreAs("audio/welcome.mp3", "public") +``` + +You may also generate and store in one step: + +```go +path, err := facades.AI().Audio("Welcome to Goravel"). + Female(). + StoreAs("audio/welcome.mp3", "public") +``` + +## Transcription + +Use `facades.AI().Transcription` to convert audio files to text. The input must implement `ai.StorableFile`, so you can reuse document attachments or any custom file type that exposes `FileName`, `MimeType`, and `Content`: + +```go +import ( + "fmt" + "time" + + "github.com/goravel/framework/ai/document" + "goravel/app/facades" +) + +response, err := facades.AI().Transcription(document.FromPath("storage/app/audio/meeting.mp3")). + Provider("openai"). + Model("gpt-4o-mini-transcribe"). + Language("en"). + Diarize(). + Timeout(30 * time.Second). + Generate() +if err != nil { + return err +} + +fmt.Println(response.Text()) +``` + +Use `Segments` when the provider returns timestamps or speaker labels: + +```go +for _, segment := range response.Segments() { + fmt.Println(segment.Speaker, segment.Start, segment.End, segment.Text) +} +``` + +Transcription responses expose transcript text, optional segments, usage metadata, and a `Then` callback: + +```go +import ( + "github.com/goravel/framework/contracts/ai" + "goravel/app/facades" +) + +response.Then(func(response ai.TranscriptionResponse) { + facades.Log().Info(response.Text()) +}) +``` + +## Middleware + +Prompt middleware intercepts requests before they reach the provider. Middleware can mutate the prompt, call the next middleware/provider, short-circuit the provider call by returning a response directly, or register callbacks that run after the final response is available. + +The middleware examples use these imports: + +```go +import ( + "context" + "strings" + + frameworkai "github.com/goravel/framework/ai" + "github.com/goravel/framework/contracts/ai" + "goravel/app/agents" + "goravel/app/facades" +) +``` + +Middleware implements the `ai.Middleware` contract: + +```go +type TrimPromptMiddleware struct { +} + +func (r *TrimPromptMiddleware) Handle(ctx context.Context, prompt ai.AgentPrompt, next ai.Next) (ai.AgentResponse, error) { + prompt.Input = strings.TrimSpace(prompt.Input) + + return next(ctx, prompt) +} +``` + +You may also post-process the final response with `Then`: + +```go +type LogResponseMiddleware struct { +} + +func (r *LogResponseMiddleware) Handle(ctx context.Context, prompt ai.AgentPrompt, next ai.Next) (ai.AgentResponse, error) { + response, err := next(ctx, prompt) + if err != nil { + return nil, err + } + + return response.Then(func(response ai.AgentResponse) { + facades.Log().Info(response.Text()) + }), nil +} +``` + +Apply middleware to a single conversation with `WithMiddleware`: + +```go +conversation, err := facades.AI().Agent( + &agents.SupportAgent{}, + frameworkai.WithMiddleware(&TrimPromptMiddleware{}), +) +``` + +To apply middleware every time an agent is used, return it from the agent's `Middleware` method: + +```go +func (r *SupportAgent) Middleware() []ai.Middleware { + return []ai.Middleware{ + &TrimPromptMiddleware{}, + &LogResponseMiddleware{}, + } +} +``` + +Agent middleware runs before middleware passed with `WithMiddleware`. The same middleware pipeline is used by both `Prompt` and `Stream`, so cross-cutting behavior such as prompt enrichment, conversation memory, guards, and response logging can live in one place. + +## Tools + +Tools allow an agent to expose callable capabilities to the model. A tool has a unique name, a description, a JSON Schema parameter definition, and an `Execute` method that returns the tool result as a string. + +```go +type WeatherTool struct { +} + +func (r *WeatherTool) Name() string { + return "get_weather" +} + +func (r *WeatherTool) Description() string { + return "Returns the current weather for a city." +} + +func (r *WeatherTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{ + "type": "string", + }, + }, + "required": []string{"city"}, + } +} + +func (r *WeatherTool) Execute(ctx context.Context, args map[string]any) (string, error) { + city, _ := args["city"].(string) + + return fmt.Sprintf("Sunny, 25 C in %s", city), nil +} +``` + +Return the tool from your agent's `Tools` method: + +```go +func (r *SupportAgent) Tools() []ai.Tool { + return []ai.Tool{ + &WeatherTool{}, + } +} +``` + +When the model requests a tool call during `Prompt`, Goravel executes the tool, appends the tool result to the conversation, and re-prompts the model until it returns a final text response: + +```go +conversation, err := facades.AI().Agent(&agents.SupportAgent{}) +if err != nil { + return err +} + +response, err := conversation.Prompt("What's the weather in London?") +if err != nil { + return err +} + +fmt.Println(response.Text()) +``` + +Goravel limits the tool-call loop to prevent a model from requesting tools indefinitely. + +## Streaming + +Use `Stream` when you want token deltas as they are produced by the provider: + +```go +stream, err := conversation.Stream("Write a short release note.") +if err != nil { + return err +} + +err = stream.Each(func(event ai.StreamEvent) error { + switch event.Type { + case ai.StreamEventTypeTextDelta: + fmt.Print(event.Delta) + case ai.StreamEventTypeToolCall: + for _, toolCall := range event.ToolCalls { + fmt.Println("calling tool:", toolCall.Name) + } + case ai.StreamEventTypeDone: + if event.Usage != nil { + fmt.Println(event.Usage.Total()) + } + case ai.StreamEventTypeError: + return errors.New(event.Error) + } + + return nil +}) +``` + +Streaming supports the same conversation options, middleware pipeline, and tool-call loop as `Prompt`. When the model requests tools, the stream emits a `StreamEventTypeToolCall` event, Goravel executes the tools, and the stream continues with the final model response. The stream appends the user input, tool calls, tool results, and final assistant text to the conversation history only after the stream completes successfully. + +### Completion Callback + +Use `Then` to run logic after the stream has completed and the final response is available: + +```go +stream.Then(func(response ai.AgentResponse) { + facades.Log().Info(response.Text()) +}) +``` + +### HTTP Streaming + +`HTTPResponse` converts a stream into a Server-Sent Events response. This is useful in controllers and routes: + +```go +func Chat(ctx http.Context) http.Response { + conversation, err := facades.AI().WithContext(ctx).Agent(&agents.SupportAgent{}) + if err != nil { + return ctx.Response().String(500, err.Error()) + } + + stream, err := conversation.Stream(ctx.Request().Input("message")) + if err != nil { + return ctx.Response().String(500, err.Error()) + } + + return stream.HTTPResponse(ctx) +} +``` + +By default, Goravel renders SSE events with `Content-Type: text/event-stream`. You may customize the status code or event renderer: + +```go +return stream.HTTPResponse( + ctx, + frameworkai.WithStreamCode(200), + frameworkai.WithStreamRender(func(w http.StreamWriter, event ai.StreamEvent) error { + if _, err := w.WriteString(event.Delta); err != nil { + return err + } + + return w.Flush() + }), +) +``` + +## Providers + +### First-party Providers + +Goravel provides these first-party AI provider packages: + +| Provider | Package | Install Command | +| --- | --- | --- | +| OpenAI | [goravel/openai](https://github.com/goravel/openai) | `./artisan package:install github.com/goravel/openai` | +| Anthropic | [goravel/anthropic](https://github.com/goravel/anthropic) | `./artisan package:install github.com/goravel/anthropic` | +| Gemini | [goravel/gemini](https://github.com/goravel/gemini) | `./artisan package:install github.com/goravel/gemini` | + +Provider packages implement the AI provider contracts and may support different capabilities. Check the provider package README for supported features and configuration details. + +The OpenAI provider uses the Responses API for prompts, streaming, tool calling, and attachments. It also supports image generation, image edits, audio generation, transcription, media storage helpers, and provider-managed files. + +### Custom Providers + +You may build a custom provider by following the same structure as `goravel/openai`, `goravel/anthropic`, or `goravel/gemini`: implement the AI provider contracts, register a service provider, expose a facade that resolves the provider, then configure it in `config/ai.go`. + +The provider resolver accepts either an `ai.Provider` instance or a `func() (ai.Provider, error)` in the provider's `via` configuration. + +```go +"providers": map[string]any{ + "custom": map[string]any{ + "via": func() (ai.Provider, error) { + return &CustomProvider{}, nil + }, + }, +}, +``` + +A provider must implement both `Prompt` and `Stream`: + +```go +import ( + "context" + + "github.com/goravel/framework/contracts/ai" +) + +type CustomProvider struct { +} + +func (r *CustomProvider) Prompt(ctx context.Context, prompt ai.AgentPrompt) (ai.AgentResponse, error) { + // Use prompt.Attachments when calling providers that support documents or images. + // Use prompt.Tools when calling providers that support tool calling. + // Use prompt.ProviderState to keep provider-specific state across tool-call loops. + return nil, nil +} + +func (r *CustomProvider) Stream(ctx context.Context, prompt ai.AgentPrompt) (ai.StreamableAgentResponse, error) { + // Return tool calls on the final streamed response when the model requests tools. + return nil, nil +} +``` + +The agent response implementation should expose generated text with `Text`, usage metadata with `Usage`, requested tool invocations with `ToolCalls`, and completion callbacks with `Then`. For streaming, populate `StreamEvent.ToolCalls` when emitting `StreamEventTypeToolCall` events. + +Providers that support image generation should implement `ImageProvider`: + +```go +func (r *CustomProvider) Image(ctx context.Context, prompt ai.ImagePrompt) (ai.ImageResponse, error) { + // Use prompt.Prompt, prompt.Model, prompt.Size, prompt.Quality, prompt.Attachments, and prompt.Timeout. + return nil, nil +} +``` + +An image response should expose generated bytes with `Content`, the MIME type with `MimeType`, usage metadata with `Usage`, storage helpers with `Store` / `StoreAs`, and completion callbacks with `Then`. + +Providers that support audio generation should implement `AudioProvider`: + +```go +func (r *CustomProvider) Audio(ctx context.Context, prompt ai.AudioPrompt) (ai.AudioResponse, error) { + // Use prompt.Prompt, prompt.Model, prompt.Voice, prompt.Instructions, and prompt.Timeout. + return nil, nil +} +``` + +An audio response should expose generated bytes with `Content`, the MIME type with `MimeType`, usage metadata with `Usage`, storage helpers with `Store` / `StoreAs`, and completion callbacks with `Then`. + +Providers that support speech-to-text should implement `TranscriptionProvider`: + +```go +func (r *CustomProvider) Transcription(ctx context.Context, prompt ai.TranscriptionPrompt) (ai.TranscriptionResponse, error) { + // Use prompt.File, prompt.Model, prompt.Language, prompt.Diarize, and prompt.Timeout. + return nil, nil +} +``` + +A transcription response should expose transcript text with `Text`, optional timestamp or speaker segments with `Segments`, usage metadata with `Usage`, and completion callbacks with `Then`. + +Providers that support attachment uploads should also implement `FileProvider`: + +```go +import ( + "context" + + "github.com/goravel/framework/contracts/ai" +) + +func (r *CustomProvider) PutFile(ctx context.Context, file ai.StorableFile) (ai.FileResponse, error) { + content, err := file.Content(ctx) + if err != nil { + return nil, err + } + + _ = content + _ = file.FileName() + _ = file.MimeType() + + return nil, nil +} + +func (r *CustomProvider) GetFile(ctx context.Context, id string) (ai.FileResponse, error) { + return nil, nil +} + +func (r *CustomProvider) DeleteFile(ctx context.Context, id string) error { + return nil +} +``` + +Then select it per conversation: + +```go +conversation, err := facades.AI().Agent( + &agents.SupportAgent{}, + frameworkai.WithProvider("custom"), +) +``` diff --git a/en/getting-started/configuration.md b/en/getting-started/configuration.md index 320e8e676..d3c79e9df 100644 --- a/en/getting-started/configuration.md +++ b/en/getting-started/configuration.md @@ -74,7 +74,7 @@ You can use the `artisan about` command to view the framework version, configura ### Maintenance Mode -You may use the `down` command to put your application into maintenance mode. The command stores maintenance metadata at the `framework/maintenance.json` storage path: +You may use the `down` command to put your application into maintenance mode. By default, Goravel uses the `file` maintenance driver and stores maintenance metadata at the `framework/maintenance.json` storage path: ```shell ./artisan down @@ -111,6 +111,15 @@ You can also let Goravel generate a random secret for you: ./artisan down --with-secret ``` +If your application runs on multiple servers, you may use the `cache` maintenance driver so all servers share the same maintenance state. Configure the driver and, optionally, the cache store name in your `.env` file: + +```ini +APP_MAINTENANCE_DRIVER=cache +APP_MAINTENANCE_STORE=redis +``` + +If `APP_MAINTENANCE_STORE` is not set, Goravel uses the default cache store. Running `down` or `up` on one server updates the maintenance state for every server using the same cache store. + To bring the application out of maintenance mode, run the `up` command: ```shell diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index b838977db..cf3e34c5d 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -1,5 +1,9 @@ # Upgrading To v1.18 From v1.17 +## Exciting New Features 🎉 + +- [AI SDK](#ai-sdk) + ## Enhancements 🚀 - [Laravel-style collections](#laravel-style-collections) @@ -67,6 +71,34 @@ rules := map[string]any{ ## Feature Introduction +### AI SDK + +Goravel v1.18 introduces a first-party AI SDK for conversations, attachments, generated media, and speech workflows through one facade. Install the facade with `./artisan package:install ai`, then install a provider package such as `goravel/openai`, `goravel/anthropic`, or `goravel/gemini`. + +The initial release includes: + +- Agent conversations with `make:agent`, `Prompt`, `Stream`, tool calling, prompt middleware. +- Request-scoped document and image attachments from bytes, readers, paths, storage, URLs, uploads, or provider-managed file IDs. +- Image and audio generation with `Store` / `StoreAs` helpers, plus transcription with language and diarization options. + +A minimal prompt looks like this: + +```go +conversation, err := facades.AI().Agent(&agents.SupportAgent{}) +if err != nil { + return err +} + +response, err := conversation.Prompt("How do I create a controller?") +if err != nil { + return err +} + +fmt.Println(response.Text()) +``` + +[View Document](../ai/sdk.md) + ### Native validation engine and built-in rules Goravel now uses a native validation engine instead of the previous external validation dependency. The new engine supports dot notation, wildcard validation, validated data retrieval, custom filters, explicit message priority, and a broader Laravel-style built-in rule set. @@ -110,7 +142,7 @@ evens.All() // []int{2, 4, 6} ### Maintenance mode commands -Goravel now provides the `artisan down` and `artisan up` commands for application maintenance mode. The `down` command writes maintenance metadata to storage, and the `up` command removes it. +Goravel now provides the `artisan down` and `artisan up` commands for application maintenance mode. The `file` maintenance driver is used by default, so the `down` command writes maintenance metadata to storage and the `up` command removes it. ```shell ./artisan down @@ -126,6 +158,13 @@ The `down` command supports response customization with options such as `--reaso ./artisan down --with-secret ``` +For multi-server deployments, set `APP_MAINTENANCE_DRIVER=cache` to store maintenance state in a shared cache store. Use `APP_MAINTENANCE_STORE` to choose a named cache store, such as Redis: + +```ini +APP_MAINTENANCE_DRIVER=cache +APP_MAINTENANCE_STORE=redis +``` + [View Document](../getting-started/configuration.md#maintenance-mode) ### Build command supports go generate From f3bca8539bdb206716bfcf5f2ec019760789721c Mon Sep 17 00:00:00 2001 From: Bowen Date: Wed, 10 Jun 2026 18:37:58 +0800 Subject: [PATCH 04/10] docs: update AI provider failover --- en/ai/sdk.md | 67 +++++++++++++++++++++++++++++++++++++++++++++ en/upgrade/v1.18.md | 2 +- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/en/ai/sdk.md b/en/ai/sdk.md index bd713e881..74aad696c 100644 --- a/en/ai/sdk.md +++ b/en/ai/sdk.md @@ -52,6 +52,12 @@ func init() { "providers": map[string]any{ "openai": map[string]any{ "key": config.Env("OPENAI_API_KEY", ""), + "failover": map[string][]string{ + "context_length_exceeded": { + "maximum context length", + "/(?i)context.*length/", + }, + }, "url": config.Env("OPENAI_BASE_URL", ""), "via": func() (ai.Provider, error) { return openaifacades.OpenAI("openai") @@ -86,6 +92,8 @@ OPENAI_BASE_URL= `OPENAI_BASE_URL` is optional. Use it when routing requests through a proxy or an OpenAI-compatible endpoint. If a model default is empty, the provider package uses its own default model. Set `models.text.max_tokens` to limit generated text tokens; leave it as `0` to use the provider default. +The `failover` map is optional. Provider packages may use it to map provider-specific error messages to failover reasons. Plain strings use substring matching, and slash-delimited strings use Go regular expressions. + ## Creating Agents Agents define the system instructions and any initial conversation context that should be sent to the provider. @@ -181,6 +189,15 @@ conversation, err := facades.AI().Agent( ) ``` +Pass additional provider names to `WithProvider` to create an ordered failover chain. Goravel tries the next provider only when the current provider returns a failover error: + +```go +conversation, err := facades.AI().Agent( + &agents.SupportAgent{}, + frameworkai.WithProvider("openai", "anthropic"), +) +``` + If the request should use a specific Go context, call `WithContext` before creating the conversation: ```go @@ -758,6 +775,29 @@ Provider packages implement the AI provider contracts and may support different The OpenAI provider uses the Responses API for prompts, streaming, tool calling, and attachments. It also supports image generation, image edits, audio generation, transcription, media storage helpers, and provider-managed files. +### Provider Failover + +When a provider chain is configured with `WithProvider("primary", "backup")`, Goravel retries the next provider only for errors that implement `ai.FailoverError`. If every provider in the chain fails with a failover error, the last error is returned. Streaming responses can fail over before output starts; after output starts, the stream returns the current provider error instead of switching providers. + +Configure `ai.providers.openai.failover` to add OpenAI-specific error message mappings: + +```go +"openai": map[string]any{ + "key": config.Env("OPENAI_API_KEY", ""), + "failover": map[string][]string{ + "context_length_exceeded": { + "maximum context length", + "/(?i)context.*length/", + }, + }, + "via": func() (ai.Provider, error) { + return openaifacades.OpenAI("openai") + }, +}, +``` + +Each `failover` key is the reason returned by `FailoverError.Reason()`. Empty reasons or patterns are ignored. Invalid regular expressions return an error while resolving the provider. + ### Custom Providers You may build a custom provider by following the same structure as `goravel/openai`, `goravel/anthropic`, or `goravel/gemini`: implement the AI provider contracts, register a service provider, expose a facade that resolves the provider, then configure it in `config/ai.go`. @@ -801,6 +841,33 @@ func (r *CustomProvider) Stream(ctx context.Context, prompt ai.AgentPrompt) (ai. The agent response implementation should expose generated text with `Text`, usage metadata with `Usage`, requested tool invocations with `ToolCalls`, and completion callbacks with `Then`. For streaming, populate `StreamEvent.ToolCalls` when emitting `StreamEventTypeToolCall` events. +Custom providers can return `frameworkai.NewFailoverError` for provider-specific retryable errors: + +```go +import ( + frameworkai "github.com/goravel/framework/ai" + "github.com/goravel/framework/contracts/ai" +) + +return nil, frameworkai.NewFailoverError("custom", ai.FailoverReason("rate_limited"), err) +``` + +They can also compile configured `failover` rules with `frameworkai.NewFailoverRules` and wrap matching errors before returning them: + +```go +rules, err := frameworkai.NewFailoverRules("custom", providerConfig.Failover) +if err != nil { + return nil, err +} + +response, err := r.callProvider(ctx, prompt) +if err != nil { + return nil, rules.Wrap("custom", err) +} + +return response, nil +``` + Providers that support image generation should implement `ImageProvider`: ```go diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index cf3e34c5d..4fbd3933d 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -77,7 +77,7 @@ Goravel v1.18 introduces a first-party AI SDK for conversations, attachments, ge The initial release includes: -- Agent conversations with `make:agent`, `Prompt`, `Stream`, tool calling, prompt middleware. +- Agent conversations with `make:agent`, `Prompt`, `Stream`, tool calling, prompt middleware, provider failover. - Request-scoped document and image attachments from bytes, readers, paths, storage, URLs, uploads, or provider-managed file IDs. - Image and audio generation with `Store` / `StoreAs` helpers, plus transcription with language and diarization options. From 5222c2cb83c0b292cb33e9961d3e697b6bf6a9ec Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Fri, 12 Jun 2026 10:29:40 +0530 Subject: [PATCH 05/10] add telemetry docs --- .vitepress/config/en.ts | 4 + en/digging-deeper/telemetry.md | 502 +++++++++++++++++++++++++++++++++ en/upgrade/v1.18.md | 25 ++ 3 files changed, 531 insertions(+) create mode 100644 en/digging-deeper/telemetry.md diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts index 88e585e98..b00568696 100644 --- a/.vitepress/config/en.ts +++ b/.vitepress/config/en.ts @@ -316,6 +316,10 @@ function sidebarAdvanced(): DefaultTheme.SidebarItem[] { { text: 'Pluralization', link: 'pluralization' + }, + { + text: 'Telemetry', + link: 'telemetry' } ] } diff --git a/en/digging-deeper/telemetry.md b/en/digging-deeper/telemetry.md new file mode 100644 index 000000000..c4e2a9612 --- /dev/null +++ b/en/digging-deeper/telemetry.md @@ -0,0 +1,502 @@ +# Telemetry + +[[toc]] + +## Introduction + +Goravel provides an observability module built on top of [OpenTelemetry](https://opentelemetry.io) that can be operated using `facades.Telemetry()`. It allows you to collect traces, metrics, and logs from your application and export them to any OTLP-compatible backend, such as Jaeger, Prometheus, Grafana, or Datadog. + +The module is integrated with the application lifecycle: providers are configured in a single configuration file, buffered data is flushed automatically when the application shuts down, and built-in instrumentation is available for the HTTP server, the HTTP client, gRPC, and the logger. + +If you are new to OpenTelemetry, the module revolves around three signals: + +- **Traces** record the full path of a request as it travels through your services. Each trace is a tree of spans, where a span represents a single timed operation, such as an HTTP request, a database query, or a function call. +- **Metrics** are numerical measurements aggregated over time, such as request counts, durations, or memory usage. +- **Logs** are timestamped records of events, which can be linked to the trace that produced them. + +## Installation + +The telemetry module is optional, you can install it using the `package:install` command: + +```shell +./artisan package:install Telemetry +``` + +This command performs the following actions: + +- Creates the `config/telemetry.go` configuration file; +- Creates the `facades/telemetry.go` facade file; +- Registers `&telemetry.ServiceProvider{}` in `bootstrap/providers.go`; +- Adds an `otel` channel to `config/logging.go` for log export. + +## Configuration + +All of the configuration options live in the `config/telemetry.go` file. The `service` section defines the identity attached to every trace, metric, and log record, this is what observability platforms use to group your data: + +```go +"service": map[string]any{ + "name": config.Env("APP_NAME", "goravel"), + "version": config.Env("APP_VERSION", ""), + "environment": config.Env("APP_ENV", ""), +}, +``` + +You can attach additional static metadata (e.g., `k8s.pod.name`, `region`, `team`) to all telemetry data using the `resource` section. + +### Enabling Signals + +Each signal (traces, metrics, and logs) is disabled by default. To enable a signal, point its `exporter` option to one of the exporter definitions in the `exporters` section. The easiest way is through your `.env` file: + +```ini +OTEL_TRACES_EXPORTER=otlptrace +OTEL_METRICS_EXPORTER=otlpmetric +OTEL_LOGS_EXPORTER=otlplog +``` + +Setting an exporter to an empty string disables the corresponding signal entirely. + +::: tip +During local development, you can set any of these to `console` to print telemetry data directly to stdout instead of sending it to a backend. +::: + +### Exporters + +The `exporters` section defines how the data leaves your application. Each entry is referenced by name from the signal sections, three drivers are supported: `otlp`, `console`, and `custom`. + +#### OTLP + +The `otlp` driver sends data to any OpenTelemetry collector or vendor endpoint, using either `http/protobuf` (port 4318) or `grpc` (port 4317): + +```go +"otlptrace": map[string]any{ + "driver": "otlp", + "endpoint": config.Env("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "localhost:4318"), + + // Protocol: "http/protobuf" or "grpc". + "protocol": config.Env("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "http/protobuf"), + + // Set to false to require TLS/SSL. + "insecure": config.Env("OTEL_EXPORTER_OTLP_TRACES_INSECURE", true), + + // Compression: "gzip" or "" (none). + "compression": config.Env("OTEL_EXPORTER_OTLP_TRACES_COMPRESSION", ""), + + // TLS certificate file paths. Leave empty to use system roots. + "tls": map[string]any{ + "ca": config.Env("OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE", ""), + "cert": config.Env("OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE", ""), + "key": config.Env("OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY", ""), + }, + + // Retry with exponential backoff on export failure. + "retry": map[string]any{ + "enabled": true, + "initial_interval": "5s", + "max_interval": "30s", + "max_elapsed_time": "1m", + }, +}, +``` + +The `endpoint` option accepts either a bare `host:port` pair or a full URL. When a URL with a scheme is provided (e.g., `https://otlp.example.com/v1/traces`), the scheme and path determine the TLS setting and export path, and the `insecure` option is ignored. + +If your backend requires authentication, such as a vendor API key, you may attach headers to every export request using the `headers` option: + +```go +"otlptrace": map[string]any{ + "driver": "otlp", + "endpoint": config.Env("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "localhost:4318"), + "headers": map[string]string{ + "x-api-key": config.Env("OTEL_EXPORTER_API_KEY", ""), + }, +}, +``` + +The metric exporter additionally supports the `metric_temporality` option: `cumulative` (Prometheus), `delta` (Datadog/StatsD), or `lowmemory`. + +::: tip +To see your traces locally, you can run Jaeger with a single command, it accepts OTLP on the default ports and requires no extra configuration: + +```shell +docker run --rm -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/jaeger:latest +``` + +Then set `OTEL_TRACES_EXPORTER=otlptrace` and open `http://localhost:16686`. +::: + +#### Console + +The `console` driver prints telemetry data to stdout, which is useful for debugging your instrumentation locally: + +```go +"console": map[string]any{ + "driver": "console", + "pretty_print": true, +}, +``` + +#### Custom + +If you need to export data to a destination that is not supported out of the box, you may provide your own exporter using the `custom` driver. The `via` key accepts either a ready-made instance or a factory function, depending on the signal the exporter is used for: + +| Signal | Instance | Factory | +| ------- | ----------------------- | ---------------------------------------------------- | +| Traces | `sdktrace.SpanExporter` | `func(context.Context) (sdktrace.SpanExporter, error)` | +| Metrics | `sdkmetric.Reader` | `func(context.Context) (sdkmetric.Reader, error)` | +| Logs | `sdklog.Exporter` | `func(context.Context) (sdklog.Exporter, error)` | + +For example, to write spans to a file instead of stdout: + +```go +import ( + "context" + "os" + + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +"custom": map[string]any{ + "driver": "custom", + "via": func(ctx context.Context) (sdktrace.SpanExporter, error) { + file, err := os.Create("storage/logs/traces.json") + if err != nil { + return nil, err + } + + return stdouttrace.New(stdouttrace.WithWriter(file)) + }, +}, +``` + +### Sampling + +Recording every trace can be expensive in high-traffic applications. The `traces.sampler` section controls which traces are recorded: + +```go +"sampler": map[string]any{ + // If true, respects the sampling decision of the upstream service. + "parent": config.Env("OTEL_TRACES_SAMPLER_PARENT", true), + + // "always_on", "always_off" or "traceidratio" + "type": config.Env("OTEL_TRACES_SAMPLER_TYPE", "always_on"), + + // The ratio for "traceidratio" sampling, e.g., 0.1 records ~10% of traces. + "ratio": config.Env("OTEL_TRACES_SAMPLER_RATIO", 0.05), +}, +``` + +When `parent` is enabled, your service follows the sampling decision already made by the calling service, ensuring distributed traces are never broken in the middle. + +### Processors + +Traces and logs are handed to their exporters through a processor. The default `batch` processor buffers data and pushes it on an interval, which is the recommended setting for production. The `simple` processor exports each record synchronously and should only be used for debugging: + +```go +"processor": map[string]any{ + "type": config.Env("OTEL_TRACE_PROCESSOR_TYPE", "batch"), + "interval": config.Env("OTEL_TRACE_EXPORT_INTERVAL", "5s"), + "timeout": config.Env("OTEL_TRACE_EXPORT_TIMEOUT", "30s"), +}, +``` + +Metrics use a periodic reader instead, configured by `metrics.reader.interval` (default `60s`). + +## Tracing + +### Creating Spans + +To create a span, request a tracer from the `Telemetry` facade using the `Tracer` method, then call `Start`. The first argument is a `context.Context`, if the context already contains a span (for example, one started by the HTTP middleware), the new span is automatically attached as its child: + +```go +import "goravel/app/facades" + +tracer := facades.Telemetry().Tracer("app") + +ctx, span := tracer.Start(ctx.Context(), "process-order") +defer span.End() + +// Pass ctx down to create child spans +orderItems(ctx) +``` + +The name passed to `Tracer` (and `Meter`) identifies the instrumentation scope, typically your application or package name, and is shown alongside each span in your backend. + +### Span Attributes + +You can attach key-value attributes to a span using the `SetAttributes` method. The `telemetry` package re-exports the OpenTelemetry attribute helpers so you don't need to import additional packages: + +```go +import "github.com/goravel/framework/telemetry" + +span.SetAttributes( + telemetry.String("order.id", "1234"), + telemetry.Int("order.items", 3), + telemetry.Bool("order.gift", false), +) +``` + +Attributes can also be set at creation time using the `WithAttributes` option: + +```go +ctx, span := tracer.Start(ctx, "process-order", telemetry.WithAttributes( + telemetry.String("order.id", "1234"), +)) +``` + +### Span Events + +Events mark a point in time within a span, such as a cache miss or a retry attempt. You can add them using the `AddEvent` method: + +```go +span.AddEvent("cache_miss", telemetry.WithAttributes( + telemetry.String("cache.key", "user:42"), +)) +``` + +### Recording Errors + +When an operation fails, you should record the error on the span and mark its status, so failed traces can be filtered in your backend: + +```go +if err != nil { + span.RecordError(err) + span.SetStatus(telemetry.CodeError, "failed to process order") + + return err +} +``` + +### Span Kinds + +By default, spans are created with the `internal` kind. When a span represents a boundary like publishing a message to a queue or consuming one, you can declare its role using the `WithSpanKind` option: + +```go +ctx, span := tracer.Start(ctx, "orders.publish", telemetry.WithSpanKind(telemetry.SpanKindProducer)) +``` + +Available kinds: `SpanKindInternal`, `SpanKindServer`, `SpanKindClient`, `SpanKindProducer`, `SpanKindConsumer`. + +### The Current Span + +You don't always need to create a new span, often you just want to enrich the one that is already active, such as the span started by the [HTTP server middleware](#http-server). You can retrieve it from the context using the `SpanFromContext` function: + +```go +import "go.opentelemetry.io/otel/trace" + +func (r *OrderController) Store(ctx http.Context) http.Response { + span := trace.SpanFromContext(ctx.Context()) + span.SetAttributes(telemetry.String("order.id", "1234")) + + // ... +} +``` + +If there is no active span in the context, a no-op span is returned, so it is always safe to call. + +## Metrics + +To record metrics, request a meter from the `Telemetry` facade using the `Meter` method, then create instruments from it. Instruments are safe for concurrent use and should be created once and reused: + +```go +import ( + "go.opentelemetry.io/otel/metric" + + "goravel/app/facades" + "github.com/goravel/framework/telemetry" +) + +meter := facades.Telemetry().Meter("app") +``` + +A **counter** only goes up, ideal for counting processed orders or sent emails: + +```go +counter, err := meter.Int64Counter("orders.processed", + metric.WithDescription("Number of processed orders"), +) + +counter.Add(ctx, 1, metric.WithAttributes( + telemetry.String("payment.method", "card"), +)) +``` + +An **up-down counter** can also decrease, useful for tracking in-flight values: + +```go +inFlight, err := meter.Int64UpDownCounter("jobs.in_flight") + +inFlight.Add(ctx, 1) +defer inFlight.Add(ctx, -1) +``` + +A **histogram** records a distribution of values, such as durations or payload sizes: + +```go +histogram, err := meter.Float64Histogram("order.process.duration", + metric.WithUnit("s"), + metric.WithDescription("Duration of order processing"), +) + +histogram.Record(ctx, time.Since(start).Seconds()) +``` + +An **observable gauge** reports a value that is sampled rather than recorded, such as a queue depth or the number of open connections. Instead of calling it yourself, you register a callback that is invoked on every collection cycle: + +```go +_, err := meter.Int64ObservableGauge("queue.depth", + metric.WithInt64Callback(func(ctx context.Context, observer metric.Int64Observer) error { + observer.Observe(int64(queue.Len())) + + return nil + }), +) +``` + +Metrics are collected and pushed to the exporter periodically based on the `metrics.reader.interval` configuration. + +## Logs + +During installation, an `otel` channel is added to your `config/logging.go` file: + +```go +"otel": map[string]any{ + "driver": "otel", + "instrument_name": config.GetString("APP_NAME", "goravel/log"), +}, +``` + +Add the channel to your logging stack and every entry written through `facades.Log()` will also be exported as an OpenTelemetry log record, with levels, structured fields, and stack traces mapped automatically: + +```go +"stack": map[string]any{ + "driver": "stack", + "channels": []string{"daily", "otel"}, +}, +``` + +To correlate logs with the active trace, write them using `WithContext`, the trace and span IDs are attached to the record so your backend can link logs to the exact request that produced them: + +```go +facades.Log().WithContext(ctx).Info("order processed") +``` + +You can also temporarily stop exporting logs without touching your logging configuration by setting `telemetry.instrumentation.log.enabled` to `false`. If you need full control over the emitted records, you may bypass the logging facade and emit OpenTelemetry log records directly using `facades.Telemetry().Logger("app")`. + +## Automatic Instrumentation + +Goravel ships with built-in instrumentation for the most common components. Each one can be toggled in the `instrumentation` section of `config/telemetry.go`. + +### HTTP Server + +The HTTP server middleware extracts the incoming trace context, starts a server span for every request, and records standard metrics (`http.server.request.duration`, `http.server.request.body.size`, `http.server.response.body.size`). Register it in the `bootstrap/app.go` file: + +```go +import ( + telemetryhttp "github.com/goravel/framework/telemetry/instrumentation/http" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithMiddleware(func(handler configuration.Middleware) { + handler.Append(telemetryhttp.Telemetry()) + }). + Create() +} +``` + +You can skip noisy endpoints using the `excluded_paths` and `excluded_methods` configuration options: + +```go +"http_server": map[string]any{ + "enabled": config.Env("OTEL_HTTP_SERVER_ENABLED", true), + "excluded_paths": []string{"/health"}, + "excluded_methods": []string{"OPTIONS"}, +}, +``` + +For more advanced control, the middleware accepts options: `WithFilter` to skip requests programmatically, `WithSpanNameFormatter` to customize span names, and `WithMetricAttributes` to attach extra attributes to recorded metrics. + +```go +handler.Append(telemetryhttp.Telemetry( + telemetryhttp.WithFilter(func(ctx http.Context) bool { + return ctx.Request().Path() != "/internal" + }), +)) +``` + +### HTTP Client + +Outgoing requests made through the [HTTP Client](./http-client.md) are instrumented automatically, no setup required. The active trace context is injected into outgoing headers, so downstream Goravel services continue the same trace. + +You can disable it globally via `telemetry.instrumentation.http_client.enabled`, or per client by setting `enable_telemetry` to `false` in the corresponding client configuration in `config/http.go`. + +### gRPC + +gRPC instrumentation is provided through stats handlers. Register them in the `bootstrap/app.go` file using the `WithGrpcServerStatsHandlers` and `WithGrpcClientStatsHandlers` functions: + +```go +import ( + "google.golang.org/grpc/stats" + + telemetrygrpc "github.com/goravel/framework/telemetry/instrumentation/grpc" +) + +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithGrpcServerStatsHandlers(func() []stats.Handler { + return []stats.Handler{telemetrygrpc.NewServerStatsHandler()} + }). + WithGrpcClientStatsHandlers(func() map[string][]stats.Handler { + return map[string][]stats.Handler{ + "default": {telemetrygrpc.NewClientStatsHandler()}, + } + }). + Create() +} +``` + +Both handlers accept options such as `WithFilter`, `WithSpanAttributes`, and `WithMetricAttributes`, and can be toggled via the `grpc_server.enabled` and `grpc_client.enabled` configuration options. + +## Context Propagation + +The `propagators` configuration option defines how trace context crosses process boundaries. The default is the W3C `tracecontext` standard; `baggage`, `b3`, and `b3multi` (Zipkin) are also supported and can be combined as a comma-separated list. + +The built-in HTTP and gRPC instrumentation propagate context automatically. If you communicate over a custom transport (e.g., a message queue), you can inject and extract the context manually using the `Propagator` method: + +```go +import "github.com/goravel/framework/telemetry" + +// Producer: inject the trace context into the message +carrier := telemetry.PropagationMapCarrier(message.Headers) +facades.Telemetry().Propagator().Inject(ctx, carrier) + +// Consumer: continue the trace from the message +ctx := facades.Telemetry().Propagator().Extract(context.Background(), telemetry.PropagationMapCarrier(message.Headers)) +``` + +If you need the identifiers of the current trace, for example, to return them in an error response, you can read them from the context: + +```go +import "go.opentelemetry.io/otel/trace" + +spanCtx := trace.SpanContextFromContext(ctx) + +traceID := spanCtx.TraceID().String() +spanID := spanCtx.SpanID().String() +``` + +## Flushing & Shutdown + +You don't need to manage the telemetry lifecycle yourself: when the application stops, Goravel automatically flushes all buffered data and shuts the providers down, waiting at most `shutdown_timeout` (default `15s`). + +If you need to push buffered data immediately without stopping the providers, for example, before a serverless function freezes, you can use the `ForceFlush` method: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +if err := facades.Telemetry().ForceFlush(ctx); err != nil { + facades.Log().Error(err) +} +``` diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index 4fbd3933d..9c3b3afde 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -3,6 +3,7 @@ ## Exciting New Features 🎉 - [AI SDK](#ai-sdk) +- [Telemetry](#telemetry) ## Enhancements 🚀 @@ -99,6 +100,30 @@ fmt.Println(response.Text()) [View Document](../ai/sdk.md) +### Telemetry + +Goravel v1.18 introduces a telemetry module built on top of OpenTelemetry for collecting traces, metrics, and logs and exporting them to any OTLP-compatible backend, such as Jaeger, Prometheus, Grafana, or Datadog. Install the facade with `./artisan package:install Telemetry`. + +The initial release includes: + +- Traces, metrics, and logs configured from a single `config/telemetry.go` file, with OTLP, console, and custom exporters, sampling, and batching controls. +- Built-in instrumentation for the HTTP server, the HTTP client, gRPC, and the logger, with trace context propagation across services. +- Automatic flush and shutdown when the application stops. + +A manual span looks like this: + +```go +ctx, span := facades.Telemetry().Tracer("app").Start(ctx.Context(), "process-order") +defer span.End() + +span.SetAttributes(telemetry.String("order.id", "1234")) + +// Pass ctx down to create child spans +processItems(ctx) +``` + +[View Document](../digging-deeper/telemetry.md) + ### Native validation engine and built-in rules Goravel now uses a native validation engine instead of the previous external validation dependency. The new engine supports dot notation, wildcard validation, validated data retrieval, custom filters, explicit message priority, and a broader Laravel-style built-in rule set. From 7ec19874fdf873b18c02d32701e1111c19704699 Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Fri, 12 Jun 2026 18:14:19 +0530 Subject: [PATCH 06/10] docs: use complete in-project examples in telemetry docs Co-Authored-By: Claude Fable 5 --- en/digging-deeper/telemetry.md | 185 ++++++++++++++++++++++++++------- 1 file changed, 146 insertions(+), 39 deletions(-) diff --git a/en/digging-deeper/telemetry.md b/en/digging-deeper/telemetry.md index c4e2a9612..ecdbb3bb4 100644 --- a/en/digging-deeper/telemetry.md +++ b/en/digging-deeper/telemetry.md @@ -206,18 +206,69 @@ Metrics use a periodic reader instead, configured by `metrics.reader.interval` ( ### Creating Spans -To create a span, request a tracer from the `Telemetry` facade using the `Tracer` method, then call `Start`. The first argument is a `context.Context`, if the context already contains a span (for example, one started by the HTTP middleware), the new span is automatically attached as its child: +To create a span, request a tracer from the `Telemetry` facade using the `Tracer` method, then call `Start`. The first argument is a `context.Context`, if the context already contains a span (for example, one started by the HTTP middleware), the new span is automatically attached as its child. + +The following service traces an order through its processing steps: the `Process` method opens a span, records what happened on it, and passes the returned context down so that `chargePayment` becomes a child span within the same trace: ```go -import "goravel/app/facades" +// app/services/order_service.go +package services + +import ( + "context" + + "github.com/goravel/framework/telemetry" + + "goravel/app/facades" +) + +type OrderService struct { +} + +func (r *OrderService) Process(ctx context.Context, orderID string) error { + ctx, span := facades.Telemetry().Tracer("app").Start(ctx, "order.process") + defer span.End() + + span.SetAttributes(telemetry.String("order.id", orderID)) + + if err := r.chargePayment(ctx, orderID); err != nil { + span.RecordError(err) + span.SetStatus(telemetry.CodeError, "failed to charge payment") + + return err + } + + span.AddEvent("payment_charged") + + return nil +} -tracer := facades.Telemetry().Tracer("app") +func (r *OrderService) chargePayment(ctx context.Context, orderID string) error { + // This span automatically becomes a child of "order.process". + _, span := facades.Telemetry().Tracer("app").Start(ctx, "order.charge_payment") + defer span.End() -ctx, span := tracer.Start(ctx.Context(), "process-order") -defer span.End() + // Charge the payment... -// Pass ctx down to create child spans -orderItems(ctx) + return nil +} +``` + +When the service is called from a controller, pass `ctx.Context()`. If the [HTTP server middleware](#http-server) is registered, the spans are attached to the request's trace, so your backend shows the full picture: the HTTP request, the order processing, and the payment charge as one tree: + +```go +// app/http/controllers/order_controller.go +func (r *OrderController) Store(ctx http.Context) http.Response { + if err := r.orders.Process(ctx.Context(), ctx.Request().Input("order_id")); err != nil { + return ctx.Response().Status(http.StatusInternalServerError).Json(http.Json{ + "error": "failed to process order", + }) + } + + return ctx.Response().Success().Json(http.Json{ + "message": "order processed", + }) +} ``` The name passed to `Tracer` (and `Meter`) identifies the instrumentation scope, typically your application or package name, and is shown alongside each span in your backend. @@ -256,7 +307,7 @@ span.AddEvent("cache_miss", telemetry.WithAttributes( ### Recording Errors -When an operation fails, you should record the error on the span and mark its status, so failed traces can be filtered in your backend: +When an operation fails, two calls work together: `RecordError` attaches the error to the span as an event, and `SetStatus` marks the whole span as failed so it can be filtered in your backend. Calling `RecordError` alone does not change the span status: ```go if err != nil { @@ -296,29 +347,63 @@ If there is no active span in the context, a no-op span is returned, so it is al ## Metrics -To record metrics, request a meter from the `Telemetry` facade using the `Meter` method, then create instruments from it. Instruments are safe for concurrent use and should be created once and reused: +To record metrics, request a meter from the `Telemetry` facade using the `Meter` method, then create instruments from it. Instruments are safe for concurrent use and should be created once and reused, a common pattern is to create them when the service is constructed and record values in its methods. + +The following service uses a **counter** (a value that only goes up, ideal for counting processed payments or sent emails) and a **histogram** (a distribution of values, such as durations or payload sizes): ```go +// app/services/payment_service.go +package services + import ( + "context" + "time" + "go.opentelemetry.io/otel/metric" - "goravel/app/facades" "github.com/goravel/framework/telemetry" + + "goravel/app/facades" ) -meter := facades.Telemetry().Meter("app") -``` +type PaymentService struct { + processed metric.Int64Counter + duration metric.Float64Histogram +} -A **counter** only goes up, ideal for counting processed orders or sent emails: +func NewPaymentService() (*PaymentService, error) { + meter := facades.Telemetry().Meter("app") + + processed, err := meter.Int64Counter("payments.processed", + metric.WithDescription("Number of processed payments"), + ) + if err != nil { + return nil, err + } + + duration, err := meter.Float64Histogram("payments.duration", + metric.WithUnit("s"), + metric.WithDescription("Duration of payment processing"), + ) + if err != nil { + return nil, err + } + + return &PaymentService{processed: processed, duration: duration}, nil +} -```go -counter, err := meter.Int64Counter("orders.processed", - metric.WithDescription("Number of processed orders"), -) +func (r *PaymentService) Charge(ctx context.Context, method string) error { + start := time.Now() -counter.Add(ctx, 1, metric.WithAttributes( - telemetry.String("payment.method", "card"), -)) + // Charge the payment... + + r.processed.Add(ctx, 1, metric.WithAttributes( + telemetry.String("payment.method", method), + )) + r.duration.Record(ctx, time.Since(start).Seconds()) + + return nil +} ``` An **up-down counter** can also decrease, useful for tracking in-flight values: @@ -330,17 +415,6 @@ inFlight.Add(ctx, 1) defer inFlight.Add(ctx, -1) ``` -A **histogram** records a distribution of values, such as durations or payload sizes: - -```go -histogram, err := meter.Float64Histogram("order.process.duration", - metric.WithUnit("s"), - metric.WithDescription("Duration of order processing"), -) - -histogram.Record(ctx, time.Since(start).Seconds()) -``` - An **observable gauge** reports a value that is sampled rather than recorded, such as a queue depth or the number of open connections. Instead of calling it yourself, you register a callback that is invoked on every collection cycle: ```go @@ -378,7 +452,15 @@ Add the channel to your logging stack and every entry written through `facades.L To correlate logs with the active trace, write them using `WithContext`, the trace and span IDs are attached to the record so your backend can link logs to the exact request that produced them: ```go -facades.Log().WithContext(ctx).Info("order processed") +func (r *OrderController) Store(ctx http.Context) http.Response { + facades.Log().WithContext(ctx.Context()). + With(map[string]any{ + "order_id": ctx.Request().Input("order_id"), + }). + Info("order received") + + // ... +} ``` You can also temporarily stop exporting logs without touching your logging configuration by setting `telemetry.instrumentation.log.enabled` to `false`. If you need full control over the emitted records, you may bypass the logging facade and emit OpenTelemetry log records directly using `facades.Telemetry().Logger("app")`. @@ -462,17 +544,42 @@ Both handlers accept options such as `WithFilter`, `WithSpanAttributes`, and `Wi The `propagators` configuration option defines how trace context crosses process boundaries. The default is the W3C `tracecontext` standard; `baggage`, `b3`, and `b3multi` (Zipkin) are also supported and can be combined as a comma-separated list. -The built-in HTTP and gRPC instrumentation propagate context automatically. If you communicate over a custom transport (e.g., a message queue), you can inject and extract the context manually using the `Propagator` method: +The built-in HTTP and gRPC instrumentation propagate context automatically. If you communicate over a custom transport, such as a message queue, you can carry the trace across the boundary yourself using the `Propagator` method: the producer injects the active context into the message headers, and the consumer extracts it and continues the same trace: ```go -import "github.com/goravel/framework/telemetry" +import ( + "context" -// Producer: inject the trace context into the message -carrier := telemetry.PropagationMapCarrier(message.Headers) -facades.Telemetry().Propagator().Inject(ctx, carrier) + "github.com/goravel/framework/telemetry" -// Consumer: continue the trace from the message -ctx := facades.Telemetry().Propagator().Extract(context.Background(), telemetry.PropagationMapCarrier(message.Headers)) + "goravel/app/facades" +) + +type Message struct { + Headers map[string]string + Body []byte +} + +func (r *OrderPublisher) Publish(ctx context.Context, message *Message) error { + // Attach the active trace context to the message + facades.Telemetry().Propagator().Inject(ctx, telemetry.PropagationMapCarrier(message.Headers)) + + // Send the message to the broker... + + return nil +} + +func (r *OrderConsumer) Consume(message *Message) error { + // Continue the trace started by the producer + ctx := facades.Telemetry().Propagator().Extract(context.Background(), telemetry.PropagationMapCarrier(message.Headers)) + + ctx, span := facades.Telemetry().Tracer("app").Start(ctx, "orders.consume", + telemetry.WithSpanKind(telemetry.SpanKindConsumer), + ) + defer span.End() + + return r.process(ctx, message) +} ``` If you need the identifiers of the current trace, for example, to return them in an error response, you can read them from the context: From 07d009d66c26aba11fcda0f289daee38cc391bc4 Mon Sep 17 00:00:00 2001 From: Bowen Date: Sat, 13 Jun 2026 10:16:57 +0800 Subject: [PATCH 07/10] optimize --- en/getting-started/configuration.md | 11 ++++++++++- en/upgrade/v1.18.md | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/en/getting-started/configuration.md b/en/getting-started/configuration.md index d3c79e9df..50e0bfdcf 100644 --- a/en/getting-started/configuration.md +++ b/en/getting-started/configuration.md @@ -74,7 +74,7 @@ You can use the `artisan about` command to view the framework version, configura ### Maintenance Mode -You may use the `down` command to put your application into maintenance mode. By default, Goravel uses the `file` maintenance driver and stores maintenance metadata at the `framework/maintenance.json` storage path: +If you installed the Route facade, you can use the `down` command to put your application into maintenance mode. By default, Goravel uses the `file` maintenance driver and stores maintenance metadata at the `framework/maintenance.json` storage path: ```shell ./artisan down @@ -111,6 +111,15 @@ You can also let Goravel generate a random secret for you: ./artisan down --with-secret ``` +The maintenance driver and cache store are configured in `config/app.go`. The default configuration reads from the `APP_MAINTENANCE_DRIVER` and `APP_MAINTENANCE_STORE` environment variables: + +```go +"maintenance": map[string]any{ + "driver": config.Env("APP_MAINTENANCE_DRIVER", "file"), + "store": config.Env("APP_MAINTENANCE_STORE", ""), +}, +``` + If your application runs on multiple servers, you may use the `cache` maintenance driver so all servers share the same maintenance state. Configure the driver and, optionally, the cache store name in your `.env` file: ```ini diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index 9c3b3afde..694d5a555 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -183,6 +183,15 @@ The `down` command supports response customization with options such as `--reaso ./artisan down --with-secret ``` +Maintenance settings are read from `app.maintenance` in `config/app.go`. New Route installations add this configuration automatically. Existing applications can add it manually: + +```go +"maintenance": map[string]any{ + "driver": config.Env("APP_MAINTENANCE_DRIVER", "file"), + "store": config.Env("APP_MAINTENANCE_STORE", ""), +}, +``` + For multi-server deployments, set `APP_MAINTENANCE_DRIVER=cache` to store maintenance state in a shared cache store. Use `APP_MAINTENANCE_STORE` to choose a named cache store, such as Redis: ```ini From 3f3f96d828765f650b622d3118ec0862f18ec914 Mon Sep 17 00:00:00 2001 From: Bowen Date: Sat, 13 Jun 2026 10:42:27 +0800 Subject: [PATCH 08/10] optimize --- en/ai/sdk.md | 42 +++++++++++++++++++++++++++++++++++++++--- en/upgrade/v1.18.md | 2 +- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/en/ai/sdk.md b/en/ai/sdk.md index 74aad696c..765893f19 100644 --- a/en/ai/sdk.md +++ b/en/ai/sdk.md @@ -18,7 +18,7 @@ Install the AI facade and core service provider with the `package:install` comma ./artisan package:install ai ``` -This makes `facades.AI()` available and registers the `make:agent` Artisan command. +This makes `facades.AI()` available and registers the `make:agent` and `make:tool` Artisan commands. ### Install Providers @@ -622,6 +622,42 @@ Agent middleware runs before middleware passed with `WithMiddleware`. The same m Tools allow an agent to expose callable capabilities to the model. A tool has a unique name, a description, a JSON Schema parameter definition, and an `Execute` method that returns the tool result as a string. +You can generate a tool with Artisan: + +```shell +./artisan make:tool WeatherTool +./artisan make:tool user/WeatherTool +``` + +The generated file is placed under `app/tools`. Nested names create subdirectories, and `--force` or `-f` overwrites an existing tool file. + +```go +package tools + +import "context" + +type WeatherTool struct { +} + +func (r *WeatherTool) Name() string { + return "weather_tool" +} + +func (r *WeatherTool) Description() string { + return "A description of the tool." +} + +func (r *WeatherTool) Parameters() map[string]any { + return nil +} + +func (r *WeatherTool) Execute(ctx context.Context, args map[string]any) (string, error) { + return "", nil +} +``` + +After generating the tool, update `Description`, `Parameters`, and `Execute` for the capability you want to expose: + ```go type WeatherTool struct { } @@ -653,12 +689,12 @@ func (r *WeatherTool) Execute(ctx context.Context, args map[string]any) (string, } ``` -Return the tool from your agent's `Tools` method: +Import your application's tools package, then return the tool from your agent's `Tools` method: ```go func (r *SupportAgent) Tools() []ai.Tool { return []ai.Tool{ - &WeatherTool{}, + &tools.WeatherTool{}, } } ``` diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index 694d5a555..ac494c36b 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -78,7 +78,7 @@ Goravel v1.18 introduces a first-party AI SDK for conversations, attachments, ge The initial release includes: -- Agent conversations with `make:agent`, `Prompt`, `Stream`, tool calling, prompt middleware, provider failover. +- Agent conversations with `make:agent`, `make:tool`, `Prompt`, `Stream`, tool calling, prompt middleware, provider failover. - Request-scoped document and image attachments from bytes, readers, paths, storage, URLs, uploads, or provider-managed file IDs. - Image and audio generation with `Store` / `StoreAs` helpers, plus transcription with language and diarization options. From fa015b241e8dac4c7b7c1b0e56704f9710dd2498 Mon Sep 17 00:00:00 2001 From: Bowen Date: Sat, 13 Jun 2026 11:13:03 +0800 Subject: [PATCH 09/10] optimize --- en/ai/sdk.md | 28 +++++++++++++++++++---- en/getting-started/directory-structure.md | 19 +++++++++++++-- en/upgrade/v1.18.md | 2 +- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/en/ai/sdk.md b/en/ai/sdk.md index 765893f19..72b4569b6 100644 --- a/en/ai/sdk.md +++ b/en/ai/sdk.md @@ -105,7 +105,7 @@ You can generate an agent with Artisan: ./artisan make:agent user/SupportAgent ``` -The generated file is placed under `app/agents` and contains the required methods: +The generated file is placed under `app/ai/agents` and contains the required methods: ```go package agents @@ -560,7 +560,7 @@ import ( frameworkai "github.com/goravel/framework/ai" "github.com/goravel/framework/contracts/ai" - "goravel/app/agents" + "goravel/app/ai/agents" "goravel/app/facades" ) ``` @@ -629,7 +629,7 @@ You can generate a tool with Artisan: ./artisan make:tool user/WeatherTool ``` -The generated file is placed under `app/tools`. Nested names create subdirectories, and `--force` or `-f` overwrites an existing tool file. +The generated file is placed under `app/ai/tools`. Nested names create subdirectories, and `--force` or `-f` overwrites an existing tool file. ```go package tools @@ -689,7 +689,7 @@ func (r *WeatherTool) Execute(ctx context.Context, args map[string]any) (string, } ``` -Import your application's tools package, then return the tool from your agent's `Tools` method: +Import your application's tools package, such as `goravel/app/ai/tools`, then return the tool from your agent's `Tools` method: ```go func (r *SupportAgent) Tools() []ai.Tool { @@ -717,6 +717,26 @@ fmt.Println(response.Text()) Goravel limits the tool-call loop to prevent a model from requesting tools indefinitely. +## Custom Generator Paths + +By default, `make:agent` writes to `app/ai/agents` and `make:tool` writes to `app/ai/tools`. You may customize those paths in `bootstrap/app.go` with `WithPaths`: + +```go +package bootstrap + +import ( + "github.com/goravel/framework/contracts/foundation/configuration" + "github.com/goravel/framework/foundation" +) + +var App = foundation.Setup(). + WithPaths(func(paths configuration.Paths) { + paths.Agents("internal/ai/agents") + paths.Tools("internal/ai/tools") + }). + Create() +``` + ## Streaming Use `Stream` when you want token deltas as they are produced by the provider: diff --git a/en/getting-started/directory-structure.md b/en/getting-started/directory-structure.md index 4d9e480c3..f7e1065de 100644 --- a/en/getting-started/directory-structure.md +++ b/en/getting-started/directory-structure.md @@ -11,6 +11,9 @@ The default file structure can make you better start project advancement, and yo ``` goravel/ ├── app/ # Core application logic +│ ├── ai/ # AI agents and tools +│ │ ├── agents/ # AI agent classes +│ │ └── tools/ # AI tool classes │ ├── console/ # Artisan console commands │ ├── grpc/ # gRPC controllers and middleware │ ├── http/ # HTTP controllers and middleware @@ -39,7 +42,7 @@ goravel/ ## Customize Directory Structure -You can customize the directory structure by calling the `WithPath()` function in the `bootstrap/app.go` file. For example, if you want to change the default `app` directory to `src`, you can modify the `bootstrap/app.go` file as follows: +You can customize the directory structure by calling the `WithPaths()` function in the `bootstrap/app.go` file. For example, if you want to change the default `app` directory to `src`, you can modify the `bootstrap/app.go` file as follows: ```go func Boot() contractsfoundation.Application { @@ -52,4 +55,16 @@ func Boot() contractsfoundation.Application { } ``` -There are many other paths you can customize, such as `Config`, `Database`, `Routes`, `Storage`, and `Resources`. Just call the corresponding method on the `paths` object to set your desired directory. +There are many other paths you can customize, such as `Config`, `Database`, `Routes`, `Storage`, and `Resources`. AI generator paths can also be customized with `Agents` and `Tools`: + +```go +func Boot() contractsfoundation.Application { + return foundation.Setup(). + WithPaths(func(paths configuration.Paths) { + paths.Agents("internal/ai/agents") + paths.Tools("internal/ai/tools") + }). + WithConfig(config.Boot). + Create() +} +``` diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index ac494c36b..297739119 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -78,7 +78,7 @@ Goravel v1.18 introduces a first-party AI SDK for conversations, attachments, ge The initial release includes: -- Agent conversations with `make:agent`, `make:tool`, `Prompt`, `Stream`, tool calling, prompt middleware, provider failover. +- Agent conversations with `make:agent`, `make:tool`, customizable AI generator paths, `Prompt`, `Stream`, tool calling, prompt middleware, provider failover. - Request-scoped document and image attachments from bytes, readers, paths, storage, URLs, uploads, or provider-managed file IDs. - Image and audio generation with `Store` / `StoreAs` helpers, plus transcription with language and diarization options. From ba2f259856060ad712ac9032905f7dbcb3f1244e Mon Sep 17 00:00:00 2001 From: Bowen Date: Sat, 13 Jun 2026 17:17:21 +0800 Subject: [PATCH 10/10] docs: document mail content breaking change --- en/digging-deeper/mail.md | 28 ++++++++++++++--- en/upgrade/v1.18.md | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/en/digging-deeper/mail.md b/en/digging-deeper/mail.md index d4b9937dd..ca78e930c 100644 --- a/en/digging-deeper/mail.md +++ b/en/digging-deeper/mail.md @@ -25,6 +25,17 @@ err := facades.Mail().To([]string{"example@example.com"}). Send() ``` +To send raw plain-text body content, set the `Text` field. Goravel sends `Text` as the plain-text email part and does not render it as a template path: + +```go +import "github.com/goravel/framework/mail" + +err := facades.Mail().To([]string{"example@example.com"}). + Subject("Subject"). + Content(mail.Content{Text: "Hello Goravel"}). + Send() +``` + ## Send Mail By Queue ```go @@ -104,7 +115,10 @@ func (m *OrderShipped) Attachments() []string { } func (m *OrderShipped) Content() *mail.Content { - return &mail.Content{Html: "

Hello Goravel

"} + return &mail.Content{ + Html: "

Hello Goravel

", + Text: "Hello Goravel", + } } func (m *OrderShipped) Envelope() *mail.Envelope { @@ -162,16 +176,23 @@ Create your email templates in the specified views directory. For example:

Thank you for joining {{.AppName}}.

``` +```text +# resources/views/mail/welcome.txt +Welcome {{.Name}}! +Thank you for joining {{.AppName}}. +``` + ### Sending Emails with Templates -You can use the `Content` method to specify the template and pass dynamic data: +You can use the `Content` method to specify the template and pass dynamic data. Use `HtmlView` for HTML templates and `TextView` for plain-text templates: ```go facades.Mail(). To([]string{"user@example.com"}). Subject("Welcome"). Content(mail.Content{ - View: "welcome.tmpl", + HtmlView: "welcome.html", + TextView: "welcome.txt", With: map[string]any{ "Name": "John", "AppName": "Goravel", @@ -197,4 +218,3 @@ You can also register custom template engines in the configuration: }, } ``` - diff --git a/en/upgrade/v1.18.md b/en/upgrade/v1.18.md index 297739119..e0e83047c 100644 --- a/en/upgrade/v1.18.md +++ b/en/upgrade/v1.18.md @@ -17,6 +17,10 @@ - [Artisan console supports table output](#artisan-console-supports-table-output) - [Log WithContext filters framework context keys out](#log-withcontext-filters-framework-context-keys-out) +## Breaking Changes 🛠 + +- [Mail content separates raw text from template views](#mail-content-separates-raw-text-from-template-views) + ## Upgrade Guide ### 1. Update dependencies @@ -70,6 +74,34 @@ rules := map[string]any{ } ``` +### 3. Update mail template content fields + +If your application uses mail templates, update every `mail.Content` value that points to a template file: + +- Replace `View` with `HtmlView` for HTML templates. +- Replace `Text` with `TextView` if the value is a plain-text template path. +- Keep `Text` only when the value is the raw plain-text email body. + +For template-based emails, migrate fields like this: + +```diff + Content(mail.Content{ +- View: "welcome.html", +- Text: "welcome.txt", ++ HtmlView: "welcome.html", ++ TextView: "welcome.txt", + With: map[string]any{ + "Name": "Goravel", + }, + }) +``` + +For raw plain-text emails, use `Text` directly: + +```go +Content(mail.Content{Text: "Hello Goravel"}) +``` + ## Feature Introduction ### AI SDK @@ -289,3 +321,34 @@ func (receiver *ReportCommand) Handle(ctx console.Context) error { Previously, `facades.Log().WithContext(ctx)` could write framework-internal context keys such as `GoravelAuthJwt` and `goravel_http_client_name` to log output. Goravel now excludes these keys by default, and you can add `logging.context.exclude` for project-specific context keys. [View Document](../the-basics/logging.md#inject-context) + +### Mail content separates raw text from template views + +Goravel now treats `mail.Content.Text` as raw plain-text email body content. Template paths are explicit: use `HtmlView` for HTML templates and `TextView` for plain-text templates. + +Use `Html` and `Text` when the message body is already available: + +```go +func (m *WelcomeMail) Content() *mail.Content { + return &mail.Content{ + Html: "

Hello Goravel

", + Text: "Hello Goravel", + } +} +``` + +Use `HtmlView` and `TextView` when the message body should be rendered from templates: + +```go +func (m *WelcomeMail) Content() *mail.Content { + return &mail.Content{ + HtmlView: "welcome.html", + TextView: "welcome.txt", + With: map[string]any{ + "Name": "Goravel", + }, + } +} +``` + +[View Document](../digging-deeper/mail.md#using-template)