diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index 12e7dad..7951d5e 100644
--- a/docs/.vuepress/config.js
+++ b/docs/.vuepress/config.js
@@ -75,7 +75,75 @@ export default defineUserConfig({
children: [
{
text: "Invokable",
- link: "engines/invokable",
+ collapsible: true,
+ children: [
+ {
+ text: "Overview",
+ link: "engines/invokable/",
+ },
+ {
+ text: "Annotations",
+ collapsible: true,
+ children: [
+ {
+ text: "Overview",
+ link: "engines/invokable/annotations/",
+ },
+ {
+ text: "Authorize",
+ link: "engines/invokable/annotations/authorize",
+ },
+ {
+ text: "SkipIf",
+ link: "engines/invokable/annotations/skip-if",
+ },
+ {
+ text: "Trim",
+ link: "engines/invokable/annotations/trim",
+ },
+ {
+ text: "Sanitize",
+ link: "engines/invokable/annotations/sanitize",
+ },
+ {
+ text: "Cast",
+ link: "engines/invokable/annotations/cast",
+ },
+ {
+ text: "DefaultValue",
+ link: "engines/invokable/annotations/default-value",
+ },
+ {
+ text: "MapValue",
+ link: "engines/invokable/annotations/map-value",
+ },
+ {
+ text: "Explode",
+ link: "engines/invokable/annotations/explode",
+ },
+ {
+ text: "Required",
+ link: "engines/invokable/annotations/required",
+ },
+ {
+ text: "In",
+ link: "engines/invokable/annotations/in",
+ },
+ {
+ text: "Between",
+ link: "engines/invokable/annotations/between",
+ },
+ {
+ text: "Regex",
+ link: "engines/invokable/annotations/regex",
+ },
+ {
+ text: "Scope",
+ link: "engines/invokable/annotations/scope",
+ },
+ ],
+ },
+ ],
},
{
text: "Tree",
diff --git a/docs/engines/invokable/annotations/authorize.md b/docs/engines/invokable/annotations/authorize.md
new file mode 100644
index 0000000..8fb6746
--- /dev/null
+++ b/docs/engines/invokable/annotations/authorize.md
@@ -0,0 +1,84 @@
+---
+sidebarDepth: 1
+---
+
+# #[Authorize]
+
+**Stage:** `CONTROL` (1)
+
+Requires authorization before the filter method executes. If authorization fails, the filter is skipped entirely.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+| ------------ | -------- | -------- | ------------------------------------------------------------------- |
+| `$authorize` | `string` | ✅ | Fully qualified class name implementing the `Authorizable` contract |
+
+---
+
+## Usage
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Authorize;
+
+#[Authorize(AdminOnly::class)]
+protected function secretField(Payload $payload)
+{
+ return $this->builder->where('secret_field', $payload->value);
+}
+```
+
+---
+
+## Authorizable Contract
+
+The class passed to `#[Authorize]` must implement `Kettasoft\Filterable\Contracts\Authorizable`:
+
+```php
+user()?->is_admin ?? false;
+ }
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| -------------------------------------- | ------------------------------------------------- |
+| `authorize()` returns `true` | Filter method executes normally |
+| `authorize()` returns `false` | Filter is **skipped** (SkipExecution is thrown) |
+| Class doesn't implement `Authorizable` | `InvalidArgumentException` is thrown |
+
+---
+
+## Example: Role-Based Filter Access
+
+```php
+class RoleFilter implements Authorizable
+{
+ public function authorize(): bool
+ {
+ return auth()->user()?->hasRole('manager');
+ }
+}
+
+// In your filter class:
+#[Authorize(RoleFilter::class)]
+protected function salary(Payload $payload)
+{
+ return $this->builder->where('salary', '>=', $payload->value);
+}
+```
diff --git a/docs/engines/invokable/annotations/between.md b/docs/engines/invokable/annotations/between.md
new file mode 100644
index 0000000..93bd32d
--- /dev/null
+++ b/docs/engines/invokable/annotations/between.md
@@ -0,0 +1,86 @@
+---
+sidebarDepth: 1
+---
+
+# #[Between]
+
+**Stage:** `VALIDATE` (3)
+
+Validates that the payload value falls within a specified numeric range. If the value is outside the range or not numeric, the filter is **skipped**.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+| --------- | ------------- | -------- | --------------------- |
+| `$min` | `float\|int` | ✅ | Minimum allowed value |
+| `$max` | `float\|int` | ✅ | Maximum allowed value |
+
+---
+
+## Usage
+
+### Integer Range
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Between;
+
+#[Between(min: 1, max: 100)]
+protected function views(Payload $payload)
+{
+ return $this->builder->where('views', '>=', $payload->value);
+}
+```
+
+### Float Range
+
+```php
+#[Between(min: 0.0, max: 5.0)]
+protected function rating(Payload $payload)
+{
+ return $this->builder->where('rating', '>=', $payload->value);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| ----------------------------------- | ------------------------------------ |
+| Value is numeric and within range | Filter executes normally |
+| Value is at the minimum boundary | Filter executes normally (**inclusive**) |
+| Value is at the maximum boundary | Filter executes normally (**inclusive**) |
+| Value is below the range | Filter is **skipped** |
+| Value is above the range | Filter is **skipped** |
+| Value is not numeric | Filter is **skipped** |
+
+---
+
+## Boundary Behavior
+
+The check is **inclusive** on both ends:
+
+```php
+#[Between(min: 1, max: 100)]
+// 1 → ✅ passes
+// 50 → ✅ passes
+// 100 → ✅ passes
+// 0 → ❌ skipped
+// 101 → ❌ skipped
+```
+
+---
+
+## Combining with Other Attributes
+
+```php
+#[SkipIf('empty')]
+#[Trim]
+#[Between(min: 1, max: 1000)]
+protected function price(Payload $payload)
+{
+ return $this->builder->where('price', '>=', $payload->value);
+}
+```
diff --git a/docs/engines/invokable/annotations/cast.md b/docs/engines/invokable/annotations/cast.md
new file mode 100644
index 0000000..ff4aa73
--- /dev/null
+++ b/docs/engines/invokable/annotations/cast.md
@@ -0,0 +1,80 @@
+---
+sidebarDepth: 1
+---
+
+# #[Cast]
+
+**Stage:** `TRANSFORM` (2)
+
+Casts the payload value to a specific type using the Payload's `as*` methods.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+| --------- | -------- | -------- | ---------------------------------------------------- |
+| `$type` | `string` | ✅ | The target type name (maps to `Payload::as{Type}()`) |
+
+---
+
+## Supported Types
+
+| Type | Maps To | Description |
+| --------- | ----------------------- | ------------------------------------ |
+| `int` | `$payload->asInt()` | Cast to integer |
+| `boolean` | `$payload->asBoolean()` | Cast to boolean |
+| `array` | `$payload->asArray()` | Decode JSON or return existing array |
+| `carbon` | `$payload->asCarbon()` | Parse to Carbon date instance |
+| `slug` | `$payload->asSlug()` | Convert to URL-friendly slug |
+| `like` | `$payload->asLike()` | Wrap with `%` for LIKE queries |
+
+---
+
+## Usage
+
+### Cast to Integer
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Cast;
+
+#[Cast('int')]
+protected function views(Payload $payload)
+{
+ // "42" → 42
+ return $this->builder->where('views', '>=', $payload->value);
+}
+```
+
+### Cast to Boolean
+
+```php
+#[Cast('boolean')]
+protected function isFeatured(Payload $payload)
+{
+ // "true" → true, "false" → false
+ return $this->builder->where('is_featured', $payload->value);
+}
+```
+
+### Cast to Array (from JSON)
+
+```php
+#[Cast('array')]
+protected function tags(Payload $payload)
+{
+ // '["php","laravel"]' → ['php', 'laravel']
+ $tags = $payload->value;
+ return $this->builder->whereIn('tag', $tags);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| -------------------------- | ------------------------------- |
+| Cast type is supported | Value is cast and returned |
+| Cast type is not supported | `StrictnessException` is thrown |
+| Cast fails (invalid value) | `StrictnessException` is thrown |
diff --git a/docs/engines/invokable/annotations/default-value.md b/docs/engines/invokable/annotations/default-value.md
new file mode 100644
index 0000000..c4b96b6
--- /dev/null
+++ b/docs/engines/invokable/annotations/default-value.md
@@ -0,0 +1,66 @@
+---
+sidebarDepth: 1
+---
+
+# #[DefaultValue]
+
+**Stage:** `TRANSFORM` (2)
+
+Sets a fallback value when the payload value is empty or null. The filter method still executes, but with the default value instead of the empty input.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+| --------- | ------- | -------- | ------------------------------------ |
+| `$value` | `mixed` | ✅ | The default value to use as fallback |
+
+---
+
+## Usage
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\DefaultValue;
+
+#[DefaultValue('active')]
+protected function status(Payload $payload)
+{
+ // If status is empty → uses "active"
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+### With Numeric Default
+
+```php
+#[DefaultValue(10)]
+protected function perPage(Payload $payload)
+{
+ // If perPage is empty → uses 10
+ return $this->builder->limit($payload->value);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| ------------------------------ | ----------------------------------------- |
+| Value is empty or null | Payload value is set to the default |
+| Value is provided (non-empty) | Default is **not** applied, original kept |
+
+---
+
+## Combining with Other Attributes
+
+```php
+#[DefaultValue('pending')]
+#[In('active', 'pending', 'archived')]
+protected function status(Payload $payload)
+{
+ // Empty input → "pending" → passes In validation
+ return $this->builder->where('status', $payload->value);
+}
+```
diff --git a/docs/engines/invokable/annotations/explode.md b/docs/engines/invokable/annotations/explode.md
new file mode 100644
index 0000000..8f32850
--- /dev/null
+++ b/docs/engines/invokable/annotations/explode.md
@@ -0,0 +1,68 @@
+---
+sidebarDepth: 1
+---
+
+# #[Explode]
+
+**Stage:** `VALIDATE` (3)
+
+Splits a string value into an array using a specified delimiter. The payload value is **overwritten** with the resulting array, making it ready for `whereIn` and similar queries.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Default | Description |
+| ------------ | -------- | -------- | ------- | ------------------------------ |
+| `$delimiter` | `string` | ❌ | `','` | The delimiter to split by |
+
+---
+
+## Usage
+
+### Default Delimiter (Comma)
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Explode;
+
+#[Explode]
+protected function tags(Payload $payload)
+{
+ // "php,laravel,testing" → ["php", "laravel", "testing"]
+ return $this->builder->whereIn('tag', $payload->value);
+}
+```
+
+### Custom Delimiter
+
+```php
+#[Explode('|')]
+protected function categories(Payload $payload)
+{
+ // "news|sports|tech" → ["news", "sports", "tech"]
+ return $this->builder->whereIn('category', $payload->value);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| ------------------------- | ----------------------------------------------- |
+| Value is a string | Split into array, payload value is overwritten |
+| Value is already an array | Returned as-is |
+
+---
+
+## Combining with Other Attributes
+
+```php
+#[Trim]
+#[Explode(',')]
+protected function statuses(Payload $payload)
+{
+ // " active,pending,archived " → ["active", "pending", "archived"]
+ return $this->builder->whereIn('status', $payload->value);
+}
+```
diff --git a/docs/engines/invokable/annotations/in.md b/docs/engines/invokable/annotations/in.md
new file mode 100644
index 0000000..1c27657
--- /dev/null
+++ b/docs/engines/invokable/annotations/in.md
@@ -0,0 +1,79 @@
+---
+sidebarDepth: 1
+---
+
+# #[In]
+
+**Stage:** `VALIDATE` (3)
+
+Validates that the payload value is within a predefined set of allowed values. If the value is not in the set, the filter is **skipped** silently.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+| ------------ | ------- | -------- | ------------------------------------ |
+| `...$values` | `mixed` | ✅ | The allowed values (variadic) |
+
+---
+
+## Usage
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\In;
+
+#[In('active', 'pending', 'archived')]
+protected function status(Payload $payload)
+{
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| --------------------------- | ------------------------------------------- |
+| Value is in the allowed set | Filter executes normally |
+| Value is **not** in the set | Filter is **skipped** (SkipExecution thrown) |
+
+---
+
+## Examples
+
+### Restrict to Specific Types
+
+```php
+#[In('post', 'page', 'article')]
+protected function type(Payload $payload)
+{
+ return $this->builder->where('type', $payload->value);
+}
+```
+
+### Numeric Values
+
+```php
+#[In(1, 2, 3, 4, 5)]
+protected function rating(Payload $payload)
+{
+ return $this->builder->where('rating', $payload->value);
+}
+```
+
+---
+
+## Combining with Transform Attributes
+
+```php
+#[Trim]
+#[Sanitize('lowercase')]
+#[In('active', 'pending', 'archived')]
+protected function status(Payload $payload)
+{
+ // " ACTIVE " → "active" → passes In check
+ return $this->builder->where('status', $payload->value);
+}
+```
diff --git a/docs/engines/invokable/annotations/index.md b/docs/engines/invokable/annotations/index.md
new file mode 100644
index 0000000..b2311d8
--- /dev/null
+++ b/docs/engines/invokable/annotations/index.md
@@ -0,0 +1,211 @@
+---
+sidebarDepth: 2
+---
+
+# Annotations (PHP Attributes)
+
+The **Invokable Engine** supports PHP 8 Attributes as a powerful declarative way to control filter behavior. Instead of writing validation, transformation, and authorization logic inside your filter methods, you declare it with attributes directly on the method signature.
+
+---
+
+## How Annotations Work
+
+When the Invokable Engine processes a filter method, it runs all declared attributes through an **Attribute Pipeline** before executing the method itself. If any attribute throws a `SkipExecution` exception, the filter method is skipped entirely. If an attribute throws a `StrictnessException`, the error propagates up.
+
+```php
+#[Trim]
+#[Sanitize('lowercase')]
+#[Required]
+#[In('active', 'pending', 'archived')]
+protected function status(Payload $payload)
+{
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+---
+
+## Execution Stages
+
+Attributes are **sorted by stage** before execution, regardless of the order you declare them. This ensures a predictable pipeline:
+
+| Order | Stage | Value | Purpose | Description |
+| ----- | ------------- | ----- | -------------------------------- | ---------------------------------------- |
+| 1 | **CONTROL** | `1` | Gate / Skip | Decide whether the filter should run |
+| 2 | **TRANSFORM** | `2` | Modify Payload | Clean, convert, or map the input value |
+| 3 | **VALIDATE** | `3` | Assert Correctness | Verify the value meets constraints |
+| 4 | **BEHAVIOR** | `4` | Affect Query | Modify query behavior directly |
+
+### Pipeline Flow
+
+```text
+Incoming Payload
+ │
+ ▼
+┌─────────────────┐
+│ CONTROL (1) │ → #[Authorize], #[SkipIf]
+│ Should we run? │ → Throws SkipExecution to abort
+└────────┬────────┘
+ │ ✓ Pass
+ ▼
+┌─────────────────┐
+│ TRANSFORM (2) │ → #[Trim], #[Sanitize], #[Cast], #[MapValue], #[DefaultValue], #[Explode]
+│ Clean the data │ → Modifies payload.value in place
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ VALIDATE (3) │ → #[Required], #[In], #[Between], #[Regex]
+│ Is data valid? │ → Throws SkipExecution or StrictnessException
+└────────┬────────┘
+ │ ✓ Pass
+ ▼
+┌─────────────────┐
+│ BEHAVIOR (4) │ → #[Scope]
+│ Affect query │ → May apply scopes or modify builder
+└────────┬────────┘
+ │
+ ▼
+ Filter Method Executes
+```
+
+---
+
+## Available Annotations
+
+### Control Stage
+
+| Attribute | Description |
+| ---------------------------------- | ------------------------------------------------------- |
+| [`#[Authorize]`](./authorize.md) | Require authorization before running the filter |
+| [`#[SkipIf]`](./skip-if.md) | Skip the filter based on a Payload condition |
+
+### Transform Stage
+
+| Attribute | Description |
+| -------------------------------------- | ---------------------------------------------------- |
+| [`#[Trim]`](./trim.md) | Remove whitespace from string values |
+| [`#[Sanitize]`](./sanitize.md) | Apply sanitization rules (lowercase, strip_tags, etc.) |
+| [`#[Cast]`](./cast.md) | Cast the value to a specific type |
+| [`#[MapValue]`](./map-value.md) | Map input values to different values |
+| [`#[DefaultValue]`](./default-value.md)| Set a fallback value when input is empty |
+| [`#[Explode]`](./explode.md) | Split a string value into an array |
+
+### Validate Stage
+
+| Attribute | Description |
+| ---------------------------------- | ------------------------------------------------------- |
+| [`#[Required]`](./required.md) | Ensure the value is present and not empty |
+| [`#[In]`](./in.md) | Validate the value is in an allowed set |
+| [`#[Between]`](./between.md) | Validate the value is within a numeric range |
+| [`#[Regex]`](./regex.md) | Validate the value matches a regex pattern |
+
+### Behavior Stage
+
+| Attribute | Description |
+| ------------------------------ | ----------------------------------------------------------- |
+| [`#[Scope]`](./scope.md) | Auto-apply an Eloquent scope with the payload value |
+
+---
+
+## Combining Attributes
+
+You can stack multiple attributes on a single method. They always execute in stage order:
+
+```php
+#[SkipIf('empty')] // Stage 1: Skip if empty
+#[Trim] // Stage 2: Remove whitespace
+#[Sanitize('lowercase', 'strip_tags')] // Stage 2: Clean the value
+#[Cast('int')] // Stage 2: Cast to integer
+#[Required] // Stage 3: Must have a value
+#[Between(min: 1, max: 1000)] // Stage 3: Range check
+protected function price(Payload $payload)
+{
+ return $this->builder->where('price', $payload->value);
+}
+```
+
+---
+
+## Creating Custom Annotations
+
+All annotations implement the `MethodAttribute` interface:
+
+```php
+value;
+ }
+
+ public function handle(AttributeContext $context): void
+ {
+ $payload = $context->payload;
+
+ if (is_string($payload->value) && mb_strlen($payload->value) < $this->length) {
+ throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution(
+ "Value must be at least {$this->length} characters."
+ );
+ }
+ }
+}
+```
+
+Usage:
+
+```php
+#[MinLength(3)]
+protected function search(Payload $payload)
+{
+ return $this->builder->where('title', 'like', $payload->asLike());
+}
+```
+
+---
+
+## AttributeContext
+
+The `AttributeContext` object passed to each annotation's `handle()` method contains:
+
+| Property | Type | Description |
+| ---------- | ------- | ------------------------------------------------- |
+| `query` | `mixed` | The Eloquent query builder instance |
+| `payload` | `mixed` | The `Payload` object with the filter value |
+| `state` | `array` | Shared state array (`method`, `key`, custom data) |
+
+You can read and write to `state` for inter-attribute communication:
+
+```php
+$context->set('my_flag', true);
+$context->get('my_flag'); // true
+$context->has('my_flag'); // true
+```
diff --git a/docs/engines/invokable/annotations/map-value.md b/docs/engines/invokable/annotations/map-value.md
new file mode 100644
index 0000000..7a6ceca
--- /dev/null
+++ b/docs/engines/invokable/annotations/map-value.md
@@ -0,0 +1,68 @@
+---
+sidebarDepth: 1
+---
+
+# #[MapValue]
+
+**Stage:** `TRANSFORM` (2)
+
+Maps incoming values to different output values using a key-value map. Useful for converting human-readable values (like `'active'`, `'inactive'`) to database values (like `1`, `0`).
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Default | Description |
+| --------- | ------- | -------- | ------- | -------------------------------------------------------- |
+| `$map` | `array` | ✅ | — | Key-value mapping (e.g., `['active' => 1]`) |
+| `$strict` | `bool` | ❌ | `false` | If `true`, skip the filter when value is not in the map |
+
+---
+
+## Usage
+
+### Basic Mapping
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\MapValue;
+
+#[MapValue(['active' => 1, 'inactive' => 0])]
+protected function status(Payload $payload)
+{
+ // "active" → 1, "inactive" → 0
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+### String to String Mapping
+
+```php
+#[MapValue(['published' => 'live', 'draft' => 'hidden'])]
+protected function visibility(Payload $payload)
+{
+ // "published" → "live", "draft" → "hidden"
+ return $this->builder->where('visibility', $payload->value);
+}
+```
+
+### Strict Mode
+
+When `strict: true`, if the incoming value is not found in the map, the filter is skipped entirely:
+
+```php
+#[MapValue(['active' => 1, 'inactive' => 0], strict: true)]
+protected function status(Payload $payload)
+{
+ // "unknown" → filter is SKIPPED
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Non-Strict (default) | Strict Mode |
+| ---------------------------------- | ------------------------------------- | ----------------------------- |
+| Value exists in map | Value is replaced with mapped value | Value is replaced |
+| Value does **not** exist in map | Original value is kept | Filter is **skipped** |
diff --git a/docs/engines/invokable/annotations/regex.md b/docs/engines/invokable/annotations/regex.md
new file mode 100644
index 0000000..3b9d5a9
--- /dev/null
+++ b/docs/engines/invokable/annotations/regex.md
@@ -0,0 +1,99 @@
+---
+sidebarDepth: 1
+---
+
+# #[Regex]
+
+**Stage:** `VALIDATE` (3)
+
+Validates that the payload value matches a given regular expression pattern. If it doesn't match, the filter is **skipped**.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Default | Description |
+| ---------- | -------- | -------- | ------- | --------------------------------------------- |
+| `$pattern` | `string` | ✅ | — | The regex pattern to match against |
+| `$message` | `string` | ❌ | `''` | Custom error message when validation fails |
+
+---
+
+## Usage
+
+### Alphabetic Only
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Regex;
+
+#[Regex('/^[a-zA-Z]+$/')]
+protected function status(Payload $payload)
+{
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+### Email Validation
+
+```php
+#[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')]
+protected function email(Payload $payload)
+{
+ return $this->builder->where('email', $payload->value);
+}
+```
+
+### Slug Pattern
+
+```php
+#[Regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/')]
+protected function slug(Payload $payload)
+{
+ return $this->builder->where('slug', $payload->value);
+}
+```
+
+### Numeric Only
+
+```php
+#[Regex('/^\d+$/')]
+protected function zipCode(Payload $payload)
+{
+ return $this->builder->where('zip_code', $payload->value);
+}
+```
+
+### Custom Error Message
+
+```php
+#[Regex('/^[A-Z]{2}-\d{4}$/', message: 'Invalid product code format. Expected: XX-1234')]
+protected function productCode(Payload $payload)
+{
+ return $this->builder->where('code', $payload->value);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| ------------------------------- | ------------------------------------ |
+| Value matches the pattern | Filter executes normally |
+| Value does **not** match | Filter is **skipped** |
+| Value is not a string | Filter is **skipped** |
+
+---
+
+## Combining with Transform Attributes
+
+```php
+#[Trim]
+#[Sanitize('lowercase')]
+#[Regex('/^[a-z0-9-]+$/')]
+protected function slug(Payload $payload)
+{
+ // " My-Slug-123 " → "my-slug-123" → passes regex
+ return $this->builder->where('slug', $payload->value);
+}
+```
diff --git a/docs/engines/invokable/annotations/required.md b/docs/engines/invokable/annotations/required.md
new file mode 100644
index 0000000..741d588
--- /dev/null
+++ b/docs/engines/invokable/annotations/required.md
@@ -0,0 +1,69 @@
+---
+sidebarDepth: 1
+---
+
+# #[Required]
+
+**Stage:** `VALIDATE` (3)
+
+Ensures the payload value is present and not empty. If the value is missing or empty, a `StrictnessException` is thrown, which **propagates up** rather than silently skipping.
+
+---
+
+## Parameters
+
+This attribute has no constructor parameters. The error message includes the parameter name automatically.
+
+---
+
+## Usage
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required;
+
+#[Required]
+protected function status(Payload $payload)
+{
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+---
+
+## Error Message
+
+When the value is empty, the exception message follows this format:
+
+```
+The parameter 'status' is required.
+```
+
+The parameter name (`status`) is taken from the filter key in the request.
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| -------------------------- | --------------------------------------------------- |
+| Value is provided | Filter executes normally |
+| Value is empty (`''`) | `StrictnessException` is thrown |
+| Value is null | `StrictnessException` is thrown |
+
+::: warning
+Unlike other validation attributes (like `#[In]` or `#[Between]`) which **skip** the filter silently, `#[Required]` throws a `StrictnessException` that propagates to the caller.
+:::
+
+---
+
+## Combining with Other Attributes
+
+```php
+#[Trim] // First: trim whitespace
+#[Required] // Then: ensure it's not empty after trimming
+#[In('active', 'pending')] // Finally: validate allowed values
+protected function status(Payload $payload)
+{
+ return $this->builder->where('status', $payload->value);
+}
+```
diff --git a/docs/engines/invokable/annotations/sanitize.md b/docs/engines/invokable/annotations/sanitize.md
new file mode 100644
index 0000000..647030e
--- /dev/null
+++ b/docs/engines/invokable/annotations/sanitize.md
@@ -0,0 +1,96 @@
+---
+sidebarDepth: 1
+---
+
+# #[Sanitize]
+
+**Stage:** `TRANSFORM` (2)
+
+Applies one or more sanitization rules to the payload value in order. This is the most versatile transform attribute, supporting multiple chained operations.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+| --------- | ---------- | -------- | --------------------------------------------- |
+| `...$rules` | `string` | ✅ | One or more sanitization rule names to apply |
+
+---
+
+## Supported Rules
+
+| Rule | Description | Example |
+| ------------- | --------------------------------------- | ------------------------------------- |
+| `lowercase` | Convert to lowercase | `"ACTIVE"` → `"active"` |
+| `uppercase` | Convert to uppercase | `"active"` → `"ACTIVE"` |
+| `ucfirst` | Capitalize first letter | `"hello world"` → `"Hello world"` |
+| `strip_tags` | Remove HTML and PHP tags | `"hello"` → `"hello"` |
+| `nl2br` | Convert newlines to `
` tags | `"a\nb"` → `"a
\nb"` |
+| `slug` | Convert to URL-friendly slug | `"Hello World"` → `"hello-world"` |
+| `trim` | Remove whitespace from both sides | `" hello "` → `"hello"` |
+
+---
+
+## Usage
+
+### Single Rule
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Sanitize;
+
+#[Sanitize('lowercase')]
+protected function status(Payload $payload)
+{
+ // "ACTIVE" → "active"
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+### Multiple Rules (Applied in Order)
+
+```php
+#[Sanitize('trim', 'strip_tags', 'lowercase')]
+protected function status(Payload $payload)
+{
+ // " ACTIVE " → "active"
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+### Generate Slug
+
+```php
+#[Sanitize('slug')]
+protected function category(Payload $payload)
+{
+ // "Hello World Post" → "hello-world-post"
+ return $this->builder->where('slug', $payload->value);
+}
+```
+
+---
+
+## Rule Order Matters
+
+Rules are applied **left to right**, so the order can affect the result:
+
+```php
+// ✅ Correct: strip tags first, then lowercase
+#[Sanitize('strip_tags', 'lowercase')]
+// "HELLO" → "HELLO" → "hello"
+
+// ⚠️ Different result: lowercase first, then strip tags
+#[Sanitize('lowercase', 'strip_tags')]
+// "HELLO" → "hello" → "hello"
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| --------------------------- | ----------------------------------------------- |
+| Value is a string | All rules are applied in order |
+| Value is not a string | No modification (silently skipped) |
+| Unknown rule name | `InvalidArgumentException` is thrown |
diff --git a/docs/engines/invokable/annotations/scope.md b/docs/engines/invokable/annotations/scope.md
new file mode 100644
index 0000000..19a65c9
--- /dev/null
+++ b/docs/engines/invokable/annotations/scope.md
@@ -0,0 +1,103 @@
+---
+sidebarDepth: 1
+---
+
+# #[Scope]
+
+**Stage:** `BEHAVIOR` (4)
+
+Automatically applies an Eloquent local scope on the query builder, passing the payload value to the scope. This allows you to reuse your model's scope methods directly from filter attributes.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Description |
+| --------- | -------- | -------- | -------------------------------------------------------------- |
+| `$scope` | `string` | ✅ | The scope name (without the `scope` prefix on the model) |
+
+---
+
+## Usage
+
+### Model with Scope
+
+```php
+// App\Models\Post
+class Post extends Model
+{
+ public function scopeActive(Builder $query, $value = null): Builder
+ {
+ return $query->where('status', $value ?? 'active');
+ }
+
+ public function scopePopular(Builder $query, $minViews = 100): Builder
+ {
+ return $query->where('views', '>=', $minViews);
+ }
+}
+```
+
+### Filter Class
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Scope;
+
+#[Scope('active')]
+protected function status(Payload $payload)
+{
+ // The scope is applied automatically before this method runs.
+ // The scope receives $payload->value as its argument.
+ // This method body also executes after the scope.
+}
+```
+
+### Using Popular Scope
+
+```php
+#[Scope('popular')]
+protected function minViews(Payload $payload)
+{
+ // Calls: $query->popular($payload->value)
+ // e.g., $query->where('views', '>=', 500)
+}
+```
+
+---
+
+## How It Works
+
+1. The attribute checks that the scope method exists on the model (`scope{Name}`).
+2. It calls `$query->{scopeName}($payload->value)` on the query builder.
+3. It sets `scope_applied = true` in the attribute context state.
+4. The filter method still executes after the scope is applied.
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| --------------------------------- | ----------------------------------------------------- |
+| Scope exists on the model | Scope is applied, then filter method executes |
+| Scope does **not** exist | `InvalidArgumentException` (caught by engine pipeline)|
+
+---
+
+## Combining with Other Attributes
+
+```php
+#[SkipIf('empty')]
+#[Trim]
+#[Sanitize('lowercase')]
+#[In('active', 'pending', 'archived')]
+#[Scope('active')]
+protected function status(Payload $payload)
+{
+ // 1. Skip if empty
+ // 2. Trim whitespace
+ // 3. Lowercase
+ // 4. Validate against allowed values
+ // 5. Apply the 'active' scope with the value
+ // 6. Filter method body runs
+}
+```
diff --git a/docs/engines/invokable/annotations/skip-if.md b/docs/engines/invokable/annotations/skip-if.md
new file mode 100644
index 0000000..a661796
--- /dev/null
+++ b/docs/engines/invokable/annotations/skip-if.md
@@ -0,0 +1,95 @@
+---
+sidebarDepth: 1
+---
+
+# #[SkipIf]
+
+**Stage:** `CONTROL` (1) — **Repeatable**
+
+Skips the filter execution if a specified condition on the `Payload` is met. Uses the Payload's `is*` methods for checks.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Default | Description |
+| ---------- | -------- | -------- | ------- | ------------------------------------------------------------------------ |
+| `$check` | `string` | ✅ | — | The Payload `is*` check name (e.g., `'empty'`, `'null'`, `'!numeric'`) |
+| `$message` | `string` | ❌ | `''` | Custom message when the filter is skipped |
+
+---
+
+## Usage
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\SkipIf;
+
+#[SkipIf('empty')]
+protected function status(Payload $payload)
+{
+ return $this->builder->where('status', $payload->value);
+}
+```
+
+---
+
+## Negation with `!`
+
+Prefix the check name with `!` to negate it:
+
+```php
+// Skip if value is NOT numeric
+#[SkipIf('!numeric')]
+protected function price(Payload $payload)
+{
+ return $this->builder->where('price', $payload->value);
+}
+```
+
+---
+
+## Available Checks
+
+Any `is*` method on the `Payload` class can be used:
+
+| Check | Maps To | Description |
+| --------------- | ---------------------- | ------------------------------------ |
+| `'empty'` | `$payload->isEmpty()` | Value is empty |
+| `'null'` | `$payload->isNull()` | Value is null |
+| `'emptyString'` | `$payload->isEmptyString()` | Value is a blank string |
+| `'numeric'` | `$payload->isNumeric()`| Value is numeric |
+| `'boolean'` | `$payload->isBoolean()`| Value is boolean-like |
+| `'string'` | `$payload->isString()` | Value is a string |
+| `'array'` | `$payload->isArray()` | Value is an array |
+| `'date'` | `$payload->isDate()` | Value is a valid date |
+| `'json'` | `$payload->isJson()` | Value is valid JSON |
+| `'!numeric'` | `!$payload->isNumeric()` | Value is **not** numeric |
+| `'!empty'` | `!$payload->isEmpty()` | Value is **not** empty |
+
+---
+
+## Stacking Multiple Checks
+
+Since `#[SkipIf]` is repeatable, you can stack multiple conditions:
+
+```php
+#[SkipIf('empty')]
+#[SkipIf('emptyString')]
+protected function title(Payload $payload)
+{
+ return $this->builder->where('title', 'like', $payload->asLike());
+}
+```
+
+Each `#[SkipIf]` is evaluated independently. If **any** of them triggers, the filter is skipped.
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| ------------------------- | ----------------------------------- |
+| Check returns `true` | Filter is **skipped** |
+| Check returns `false` | Filter executes normally |
+| Negated check (`!`) true | Filter is **skipped** |
+| Method doesn't exist | `InvalidArgumentException` is thrown |
diff --git a/docs/engines/invokable/annotations/trim.md b/docs/engines/invokable/annotations/trim.md
new file mode 100644
index 0000000..e39dee0
--- /dev/null
+++ b/docs/engines/invokable/annotations/trim.md
@@ -0,0 +1,77 @@
+---
+sidebarDepth: 1
+---
+
+# #[Trim]
+
+**Stage:** `TRANSFORM` (2)
+
+Removes whitespace (or custom characters) from the payload value before the filter method executes.
+
+---
+
+## Parameters
+
+| Parameter | Type | Required | Default | Description |
+| ------------- | -------- | -------- | ----------------------- | ---------------------------------------------- |
+| `$characters` | `string` | ❌ | `" \t\n\r\0\x0B"` | Characters to trim |
+| `$side` | `string` | ❌ | `'both'` | Side to trim: `'both'`, `'left'`, or `'right'` |
+
+---
+
+## Usage
+
+### Basic — Trim Both Sides
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim;
+
+#[Trim]
+protected function title(Payload $payload)
+{
+ // " hello world " → "hello world"
+ return $this->builder->where('title', $payload->value);
+}
+```
+
+### Trim Left Only
+
+```php
+#[Trim(side: 'left')]
+protected function title(Payload $payload)
+{
+ // " hello world " → "hello world "
+ return $this->builder->where('title', $payload->value);
+}
+```
+
+### Trim Right Only
+
+```php
+#[Trim(side: 'right')]
+protected function title(Payload $payload)
+{
+ // " hello world " → " hello world"
+ return $this->builder->where('title', $payload->value);
+}
+```
+
+### Custom Characters
+
+```php
+#[Trim(characters: '-')]
+protected function slug(Payload $payload)
+{
+ // "---hello-world---" → "hello-world"
+ return $this->builder->where('slug', $payload->value);
+}
+```
+
+---
+
+## Behavior
+
+| Scenario | Result |
+| ------------------------- | ----------------------------------- |
+| Value is a string | Whitespace/characters are trimmed |
+| Value is not a string | No modification (silently skipped) |
diff --git a/docs/engines/invokable/index.md b/docs/engines/invokable/index.md
new file mode 100644
index 0000000..f3eab77
--- /dev/null
+++ b/docs/engines/invokable/index.md
@@ -0,0 +1,259 @@
+---
+sidebarDepth: 2
+---
+
+# Invokable Engine
+
+The **Invokable Engine** is the default and most commonly used engine in Filterable. It dynamically maps incoming request parameters to corresponding methods in your filter class, enabling clean, scalable filtering logic without large `switch` or `if-else` blocks.
+
+---
+
+## Purpose
+
+Automatically execute specific methods in a filter class based on incoming request keys. Each key in the request is matched with a method of the same name (or mapped name) registered in the `$filters` property, and the method is invoked with a rich `Payload` object.
+
+---
+
+## How It Works
+
+```text
+[ Request ]
+ │
+ ▼
+[ Extract Filter Keys ] ─── from $filters property
+ │
+ ▼
+[ For Each Key ]
+ ├── Parse operator & value (Dissector)
+ ├── Create Payload (field, operator, value, rawValue)
+ ├── Run Attribute Pipeline (CONTROL → TRANSFORM → VALIDATE → BEHAVIOR)
+ ├── Call filter method with Payload
+ └── Commit clause to query
+ │
+ ▼
+[ Modified Query Builder ]
+```
+
+### Step by Step
+
+1. The request is parsed and filter keys are extracted from the `$filters` property.
+2. For each key, the engine parses the value through a **Dissector** to extract the operator and value.
+3. A `Payload` object is created containing `field`, `operator`, `value`, and `rawValue`.
+4. The **Attribute Pipeline** runs all PHP attributes (annotations) on the method, sorted by stage.
+5. If the pipeline succeeds, the filter method is invoked with the `Payload`.
+6. The resulting clause is committed to the query builder.
+
+---
+
+## Basic Example
+
+### Incoming Request
+
+```http
+GET /api/posts?status=pending&title=PHP
+```
+
+### Filter Class
+
+```php
+builder->where('title', 'like', $payload->asLike());
+ }
+
+ protected function status(Payload $payload)
+ {
+ return $this->builder->where('status', $payload->value);
+ }
+}
+```
+
+### Usage
+
+```php
+$posts = Post::filter(PostFilter::class)->paginate();
+```
+
+---
+
+## The Payload Object
+
+Every filter method receives a `Payload` instance, giving you full access to the parsed request data:
+
+| Property | Type | Description |
+| ----------- | -------- | ---------------------------------------------- |
+| `field` | `string` | The column/filter name |
+| `operator` | `string` | The parsed operator (e.g., `eq`, `like`, `gt`) |
+| `value` | `mixed` | The sanitized filter value |
+| `rawValue` | `mixed` | The original raw input before sanitization |
+
+```php
+protected function price(Payload $payload)
+{
+ return $this->builder->where('price', $payload->operator, $payload->value);
+}
+```
+
+See the full [Payload API Reference](/api/payload) for all available methods.
+
+---
+
+## Method Mapping with `$mentors`
+
+By default, the engine matches request keys directly to method names (converted to camelCase). You can customize this mapping with the `$mentors` property:
+
+```php
+class PostFilter extends Filterable
+{
+ protected $filters = ['joined', 'status'];
+
+ protected $mentors = [
+ 'joined' => 'filterByJoinDate',
+ 'status' => 'filterByStatus',
+ ];
+
+ protected function filterByJoinDate(Payload $payload)
+ {
+ return $this->builder->whereDate('joined_at', '>', $payload->value);
+ }
+
+ protected function filterByStatus(Payload $payload)
+ {
+ return $this->builder->where('status', $payload->value);
+ }
+}
+```
+
+### Automatic Fallback
+
+If `$mentors` is empty or not defined, the engine automatically matches request keys to method names:
+
+```
+'status' → calls status()
+'created_at' → calls createdAt()
+```
+
+---
+
+## Attribute Pipeline
+
+The Invokable Engine supports **PHP 8 Attributes** (annotations) on filter methods. These attributes are processed through an **Attribute Pipeline** before the filter method executes.
+
+Attributes are sorted and executed by **stage**:
+
+| Order | Stage | Purpose | Example Attributes |
+| ----- | ------------- | ---------------------------------- | ----------------------------------- |
+| 1 | **CONTROL** | Decide whether to run the filter | `#[Authorize]`, `#[SkipIf]` |
+| 2 | **TRANSFORM** | Modify the payload value | `#[Trim]`, `#[Sanitize]`, `#[Cast]`, `#[MapValue]`, `#[DefaultValue]`, `#[Explode]` |
+| 3 | **VALIDATE** | Assert correctness of the value | `#[Required]`, `#[In]`, `#[Between]`, `#[Regex]` |
+| 4 | **BEHAVIOR** | Affect query behavior | `#[Scope]` |
+
+### Example with Attributes
+
+```php
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim;
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Sanitize;
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required;
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\In;
+
+class PostFilter extends Filterable
+{
+ protected $filters = ['status', 'title'];
+
+ #[Trim]
+ #[Sanitize('lowercase')]
+ #[Required]
+ #[In('active', 'pending', 'archived')]
+ protected function status(Payload $payload)
+ {
+ return $this->builder->where('status', $payload->value);
+ }
+
+ #[Trim]
+ #[Sanitize('strip_tags')]
+ protected function title(Payload $payload)
+ {
+ return $this->builder->where('title', 'like', $payload->asLike());
+ }
+}
+```
+
+In this example, when a `status` filter is received:
+
+1. **Trim** removes whitespace from the value.
+2. **Sanitize** converts it to lowercase.
+3. **Required** ensures the value is not empty (throws exception if it is).
+4. **In** validates the value is one of the allowed options (skips if not).
+5. The filter method executes with the cleaned, validated payload.
+
+👉 See [Annotations Reference](./annotations/) for full documentation of all available attributes.
+
+---
+
+## Default Operator
+
+The default operator can be configured per engine:
+
+```php
+// config/filterable.php
+'engines' => [
+ 'invokable' => [
+ 'default_operator' => 'eq',
+ ],
+],
+```
+
+---
+
+## Key Features
+
+| Feature | Description |
+| ------------------------------ | -------------------------------------------------------------- |
+| **Convention over Configuration** | Method names match request keys automatically |
+| **Safe Execution** | Only registered filter keys in `$filters` are processed |
+| **Attribute Pipeline** | PHP 8 attributes for validation, transformation, and control |
+| **Custom Method Mapping** | `$mentors` property for flexible key-to-method mapping |
+| **Rich Payload Object** | Full access to field, operator, value, and raw value |
+| **Extensible** | Add or override filter methods easily |
+
+---
+
+## Lifecycle
+
+```text
+1. Controller receives request
+2. Post::filter(PostFilter::class) is called
+3. Engine extracts keys from $filters
+4. For each key present in the request:
+ a. Dissector parses the operator and value
+ b. Payload is created
+ c. Attribute Pipeline runs (CONTROL → TRANSFORM → VALIDATE → BEHAVIOR)
+ d. If pipeline passes, filter method is called with Payload
+ e. Clause is committed to query
+5. Modified Eloquent query is returned
+```
+
+---
+
+## Best Practices
+
+- **Always register filters** in the `$filters` property — unregistered methods won't execute.
+- **Use attributes** to keep your filter methods focused on query logic, not validation.
+- **Combine multiple attributes** — they execute in stage order, so `#[Trim]` always runs before `#[Required]`.
+- **Type-hint `Payload`** in your filter methods for full IDE support.
+- **Use `$mentors`** to decouple public API parameter names from internal method names.
+- **Validate input** using `#[Required]`, `#[In]`, `#[Between]`, or `#[Regex]` attributes.
diff --git a/src/Engines/Foundation/Attributes/Annotations/Authorize.php b/src/Engines/Foundation/Attributes/Annotations/Authorize.php
new file mode 100644
index 0000000..9c9a810
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Authorize.php
@@ -0,0 +1,44 @@
+ $authorize The class name of the authorization logic.
+ */
+ public function __construct(public string $authorize) {}
+
+ /**
+ * Get the stage at which this attribute should be applied.
+ *
+ * @return int
+ */
+ public static function stage(): int
+ {
+ return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::CONTROL->value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ if (!is_a($this->authorize, \Kettasoft\Filterable\Contracts\Authorizable::class, true)) {
+ throw new \InvalidArgumentException("The class '{$this->authorize}' must implement the Authorizable contract.");
+ }
+
+ $authorize = new $this->authorize;
+
+ if (! $authorize->authorize()) {
+ throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution("Authorization failed for class '{$this->authorize}'.");
+ }
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Between.php b/src/Engines/Foundation/Attributes/Annotations/Between.php
new file mode 100644
index 0000000..ea1b503
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Between.php
@@ -0,0 +1,57 @@
+value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ if (! is_numeric($payload->value)) {
+ throw new SkipExecution(
+ "The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}."
+ );
+ }
+
+ $value = (float) $payload->value;
+
+ if ($value < $this->min || $value > $this->max) {
+ throw new SkipExecution(
+ "The value '{$value}' is not between {$this->min} and {$this->max}."
+ );
+ }
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Cast.php b/src/Engines/Foundation/Attributes/Annotations/Cast.php
new file mode 100644
index 0000000..5e8c475
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Cast.php
@@ -0,0 +1,45 @@
+value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ * @throws StrictnessException if the parameter is missing or empty.
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ try {
+ $payload->cast($this->type);
+ } catch (\Exception $e) {
+ throw new StrictnessException($e->getMessage());
+ }
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php b/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php
index 6cd9fe3..d8f9c0b 100644
--- a/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php
+++ b/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php
@@ -5,11 +5,37 @@
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
-class DefaultValue
+class DefaultValue implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute
{
/**
* Constructor for DefaultValue attribute.
* @param mixed $value The default value to be used if none is provided.
*/
public function __construct(public mixed $value) {}
+
+ /**
+ * Get the stage at which this attribute should be applied.
+ *
+ * @return int
+ */
+ public static function stage(): int
+ {
+ return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ if ($payload->isEmpty() || $payload->isNull()) {
+ $payload->setValue($this->value);
+ }
+ }
}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Explode.php b/src/Engines/Foundation/Attributes/Annotations/Explode.php
new file mode 100644
index 0000000..7f0c590
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Explode.php
@@ -0,0 +1,39 @@
+value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ $payload->explode($this->delimiter, true);
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/In.php b/src/Engines/Foundation/Attributes/Annotations/In.php
new file mode 100644
index 0000000..baa4071
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/In.php
@@ -0,0 +1,53 @@
+values = $values;
+ }
+
+ /**
+ * Get the stage at which this attribute should be applied.
+ *
+ * @return int
+ */
+ public static function stage(): int
+ {
+ return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ if ($payload->notIn($this->values)) {
+ throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution(
+ "The value '{$payload->value}' is not in the allowed set: " . implode(', ', $this->values)
+ );
+ }
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/MapValue.php b/src/Engines/Foundation/Attributes/Annotations/MapValue.php
new file mode 100644
index 0000000..6e785f7
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/MapValue.php
@@ -0,0 +1,70 @@
+
+ */
+ protected array $map;
+
+ /**
+ * Whether to skip the filter if the value is not in the map.
+ *
+ * @var bool
+ */
+ protected bool $strict;
+
+ /**
+ * Constructor for MapValue attribute.
+ *
+ * @param array $map The value mapping (e.g., ['active' => 1, 'inactive' => 0]).
+ * @param bool $strict If true, skip execution when value is not found in map. Defaults to false.
+ */
+ public function __construct(array $map, bool $strict = false)
+ {
+ $this->map = $map;
+ $this->strict = $strict;
+ }
+
+ /**
+ * Get the stage at which this attribute should be applied.
+ *
+ * @return int
+ */
+ public static function stage(): int
+ {
+ return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ $key = (string) $payload->value;
+
+ if (array_key_exists($key, $this->map)) {
+ $payload->setValue($this->map[$key]);
+ return;
+ }
+
+ if ($this->strict) {
+ throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution(
+ "The value '{$key}' is not in the value map: " . implode(', ', array_keys($this->map))
+ );
+ }
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Regex.php b/src/Engines/Foundation/Attributes/Annotations/Regex.php
new file mode 100644
index 0000000..e478980
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Regex.php
@@ -0,0 +1,55 @@
+value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ if (! is_string($payload->value)) {
+ throw new SkipExecution(
+ $this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'."
+ );
+ }
+
+ if (! preg_match($this->pattern, $payload->value)) {
+ throw new SkipExecution(
+ $this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'."
+ );
+ }
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Required.php b/src/Engines/Foundation/Attributes/Annotations/Required.php
index 40cbf01..cd62f62 100644
--- a/src/Engines/Foundation/Attributes/Annotations/Required.php
+++ b/src/Engines/Foundation/Attributes/Annotations/Required.php
@@ -3,9 +3,41 @@
namespace Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations;
use Attribute;
+use Kettasoft\Filterable\Exceptions\StrictnessException;
#[Attribute(Attribute::TARGET_METHOD)]
-class Required
+class Required implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute
{
- public function __construct(public string $message = "The parameter '%s' is required.") {}
+ /**
+ * The error message template. %s will be replaced with the parameter name.
+ * @var string
+ */
+ public string $message = "The parameter '%s' is required.";
+
+ /**
+ * Get the stage at which this attribute should be applied.
+ *
+ * @return int
+ */
+ public static function stage(): int
+ {
+ return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ * @throws StrictnessException if the parameter is missing or empty.
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ if ($payload && ($payload->isEmpty() || $payload->isNull())) {
+ throw new StrictnessException(sprintf($this->message, $context->state['key']));
+ }
+ }
}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Sanitize.php b/src/Engines/Foundation/Attributes/Annotations/Sanitize.php
new file mode 100644
index 0000000..d0d019e
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Sanitize.php
@@ -0,0 +1,72 @@
+
+ */
+ protected array $rules;
+
+ /**
+ * Constructor for Sanitize attribute.
+ *
+ * Supported rules: 'lowercase', 'uppercase', 'ucfirst', 'strip_tags', 'nl2br', 'slug', 'trim'.
+ *
+ * @param string ...$rules The sanitization rules to apply in order.
+ */
+ public function __construct(string ...$rules)
+ {
+ $this->rules = $rules;
+ }
+
+ /**
+ * Get the stage at which this attribute should be applied.
+ *
+ * @return int
+ */
+ public static function stage(): int
+ {
+ return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ * @throws \InvalidArgumentException if a rule is not supported.
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ if (! is_string($payload->value)) {
+ return;
+ }
+
+ $value = $payload->value;
+
+ foreach ($this->rules as $rule) {
+ $value = match ($rule) {
+ 'lowercase' => mb_strtolower($value),
+ 'uppercase' => mb_strtoupper($value),
+ 'ucfirst' => ucfirst($value),
+ 'strip_tags' => strip_tags($value),
+ 'nl2br' => nl2br($value),
+ 'slug' => \Illuminate\Support\Str::slug($value),
+ 'trim' => trim($value),
+ default => throw new \InvalidArgumentException("Sanitization rule [{$rule}] is not supported."),
+ };
+ }
+
+ $payload->setValue($value);
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Scope.php b/src/Engines/Foundation/Attributes/Annotations/Scope.php
new file mode 100644
index 0000000..3206f05
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Scope.php
@@ -0,0 +1,55 @@
+value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Illuminate\Contracts\Eloquent\Builder $query */
+ $query = $context->query;
+
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ $scope = $this->scope;
+
+ if (! method_exists($query->getModel(), 'scope' . ucfirst($scope))) {
+ throw new \InvalidArgumentException(
+ "The scope '{$scope}' does not exist on the model '" . get_class($query->getModel()) . "'."
+ );
+ }
+
+ $query->{$scope}($payload->value);
+
+ // Set a flag in context to indicate the scope was applied,
+ // allowing the engine to optionally skip the filter method execution.
+ $context->set('scope_applied', true);
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/SkipIf.php b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php
new file mode 100644
index 0000000..583fa0d
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php
@@ -0,0 +1,70 @@
+value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ $check = $this->check;
+ $negate = false;
+
+ if (str_starts_with($check, '!')) {
+ $negate = true;
+ $check = substr($check, 1);
+ }
+
+ $method = 'is' . ucfirst($check);
+
+ if (! method_exists($payload, $method)) {
+ throw new \InvalidArgumentException("Check method [{$method}] does not exist on Payload.");
+ }
+
+ $result = $payload->$method();
+
+ if ($negate) {
+ $result = ! $result;
+ }
+
+ if ($result) {
+ throw new SkipExecution(
+ $this->message ?: "Filter skipped because payload {$this->check} check was true."
+ );
+ }
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/Annotations/Trim.php b/src/Engines/Foundation/Attributes/Annotations/Trim.php
new file mode 100644
index 0000000..901e92e
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Annotations/Trim.php
@@ -0,0 +1,51 @@
+value;
+ }
+
+ /**
+ * Handle the attribute logic.
+ *
+ * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context
+ * @return void
+ */
+ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void
+ {
+ /** @var \Kettasoft\Filterable\Support\Payload $payload */
+ $payload = $context->payload;
+
+ if (! is_string($payload->value)) {
+ return;
+ }
+
+ $payload->setValue(match ($this->side) {
+ 'left' => ltrim($payload->value, $this->characters),
+ 'right' => rtrim($payload->value, $this->characters),
+ default => trim($payload->value, $this->characters),
+ });
+ }
+}
diff --git a/src/Engines/Foundation/Attributes/AttributePipeline.php b/src/Engines/Foundation/Attributes/AttributePipeline.php
index 27e6bf5..3db1d0a 100644
--- a/src/Engines/Foundation/Attributes/AttributePipeline.php
+++ b/src/Engines/Foundation/Attributes/AttributePipeline.php
@@ -8,13 +8,22 @@
class AttributePipeline
{
+ /**
+ * The attribute registry instance.
+ * @var AttributeRegistry
+ */
+ protected AttributeRegistry $registry;
+
/**
* Create a new attribute pipeline instance.
*
* @param AttributeRegistry $registry
* @param AttributeContext $context
*/
- public function __construct(protected AttributeRegistry $registry, protected AttributeContext $context) {}
+ public function __construct(protected AttributeContext $context)
+ {
+ $this->registry = new AttributeRegistry();
+ }
/**
* Process the attributes for the given target and method.
@@ -27,10 +36,10 @@ public function process(Filterable $target, string $method): Outcome
$execution = new Execution();
try {
- $handlers = $this->registry->getHandlersForMethod($target, $method);
+ $attributes = $this->registry->getHandlersForMethod($target, $method);
- foreach ($handlers as [$handler, $attributeInstance]) {
- (new $handler)->handle($this->context, $attributeInstance);
+ foreach ($attributes as $attribute) {
+ $attribute->handle($this->context);
}
} catch (\Exception $e) {
$execution->fail($e);
diff --git a/src/Engines/Foundation/Attributes/AttributeRegistry.php b/src/Engines/Foundation/Attributes/AttributeRegistry.php
index c1aed3f..496fc65 100644
--- a/src/Engines/Foundation/Attributes/AttributeRegistry.php
+++ b/src/Engines/Foundation/Attributes/AttributeRegistry.php
@@ -2,56 +2,40 @@
namespace Kettasoft\Filterable\Engines\Foundation\Attributes;
-use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\DefaultValue;
-use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required;
-use ReflectionMethod;
+use Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute;
use Kettasoft\Filterable\Filterable;
-use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\Contracts\AttributeHandlerInterface;
-use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\DefaultValueHandler;
-use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\RequiredHandler;
+use ReflectionMethod;
class AttributeRegistry
{
- /**
- * @var array
- */
- protected array $handlers = [
- DefaultValue::class => DefaultValueHandler::class,
- Required::class => RequiredHandler::class,
- ];
-
- /**
- * Register an attribute handler.
- * @param string $attributeClass
- * @param AttributeHandlerInterface $handler
- * @return void
- */
- public function register(string $attributeClass, AttributeHandlerInterface $handler): void
- {
- $this->handlers[$attributeClass] = $handler;
- }
-
/**
* Get handlers for the given method of a filterable class.
*
* @param Filterable $filterable
* @param string $method
- * @return array
+ * @return array
*/
public function getHandlersForMethod(Filterable $filterable, string $method): array
{
$reflection = new ReflectionMethod($filterable, $method);
- $attributes = $reflection->getAttributes();
- $matchedHandlers = [];
-
- foreach ($attributes as $attribute) {
- $attrName = $attribute->getName();
- if (isset($this->handlers[$attrName])) {
- $handler = $this->handlers[$attrName];
- $matchedHandlers[] = [$handler, $attribute->newInstance()];
+
+ $resolved = [];
+
+ foreach ($reflection->getAttributes() as $attribute) {
+ $instance = $attribute->newInstance();
+
+ if (! $instance instanceof MethodAttribute) {
+ continue;
}
+
+ $resolved[] = $instance;
}
- return $matchedHandlers;
+ usort(
+ $resolved,
+ fn($a, $b) => $a::stage() <=> $b::stage()
+ );
+
+ return $resolved;
}
}
diff --git a/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php b/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php
new file mode 100644
index 0000000..8862244
--- /dev/null
+++ b/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php
@@ -0,0 +1,23 @@
+ $method, 'key' => $key]
);
- $pipeline = new AttributePipeline(new AttributeRegistry(), $attrContext);
+ $pipeline = new AttributePipeline($attrContext);
$process = $pipeline->process($this->context, $method);
$process->then(function () use ($method, $payload) {
diff --git a/src/Support/Payload.php b/src/Support/Payload.php
index 1cc61c0..e1e5c4e 100644
--- a/src/Support/Payload.php
+++ b/src/Support/Payload.php
@@ -508,27 +508,80 @@ public function asInt(): ?int
* If the value is a string, it will be split by the delimiter.
* If the value is already an array, it will be returned as is.
*
- * @param string $delimiter
+ * @param string $delimiter The delimiter to split the string by.
+ * @param bool $overwrite Whether to replace the original payload value. Defaults to false.
* @return array
*/
- public function explode(string $delimiter = ','): array
+ public function explode(string $delimiter = ',', bool $overwrite = false): array
{
+ if ($this->isArray()) {
+ return (array) $this->value;
+ }
+
if ($this->isString()) {
- return explode($delimiter, $this->value);
+ $exploded = explode($delimiter, $this->value);
+
+ if ($overwrite) {
+ $this->value = $exploded;
+ }
+
+ return $exploded;
}
+ // If value is neither string nor array, just return it as-is
return (array) $this->value;
}
/**
* Alias for explode method.
*
- * @param string $delimiter
+ * @param string $delimiter The delimiter to split the string by.
+ * @param bool $overwrite Whether to replace the original payload value. Defaults to false.
* @return array
*/
- public function split(string $delimiter = ','): array
+ public function split(string $delimiter = ',', bool $overwrite = false): array
+ {
+ return $this->explode($delimiter, $overwrite);
+ }
+
+ /**
+ * Cast the payload value to the given type using the corresponding as* method.
+ *
+ * Supported types: 'boolean', 'array', 'int', 'carbon', 'slug', 'like', 'json'.
+ *
+ * Example: $payload->cast('int'), $payload->cast('boolean')
+ *
+ * @param string $type
+ * @param mixed ...$args Additional arguments to pass to the cast method.
+ * @return mixed
+ *
+ * @throws \InvalidArgumentException if the cast type method does not exist.
+ */
+ public function cast(string $type, mixed ...$args): mixed
+ {
+ $method = 'as' . ucfirst($type);
+
+ if (!method_exists($this, $method)) {
+ throw new \InvalidArgumentException("Cast type [{$type}] is not supported. Method {$method} does not exist.");
+ }
+
+ $this->value = $this->$method(...$args);
+
+ return $this->value;
+ }
+
+ /**
+ * Alias for cast method.
+ *
+ * @param string $type
+ * @param mixed ...$args Additional arguments to pass to the cast method.
+ * @return mixed
+ *
+ * @throws \InvalidArgumentException if the cast type method does not exist.
+ */
+ public function as(string $type, mixed ...$args): mixed
{
- return $this->explode($delimiter);
+ return $this->cast($type, ...$args);
}
/**
diff --git a/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php b/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php
new file mode 100644
index 0000000..7f3d6d6
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php
@@ -0,0 +1,14 @@
+merge([
+ 'tags' => 'testing',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['tags'];
+
+ #[Authorize(CanMakeFilter::class)]
+ public function tags(Payload $payload)
+ {
+ $this->builder->where('tags', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('where "tags"', $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/BetweenAttributeTest.php b/tests/Feature/Engines/Attributes/BetweenAttributeTest.php
new file mode 100644
index 0000000..1df33e6
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/BetweenAttributeTest.php
@@ -0,0 +1,164 @@
+merge([
+ 'views' => '50',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Between(min: 1, max: 100)]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ $this->assertStringContainsString('50', $sql);
+ }
+
+ public function test_between_attribute_allows_value_at_minimum_boundary()
+ {
+ request()->merge([
+ 'views' => '1',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Between(min: 1, max: 100)]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ }
+
+ public function test_between_attribute_allows_value_at_maximum_boundary()
+ {
+ request()->merge([
+ 'views' => '100',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Between(min: 1, max: 100)]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ }
+
+ public function test_between_attribute_skips_filter_when_value_below_range()
+ {
+ request()->merge([
+ 'views' => '0',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Between(min: 1, max: 100)]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // Filter should be skipped, no where clause for views
+ $this->assertStringNotContainsString('"views" =', $sql);
+ }
+
+ public function test_between_attribute_skips_filter_when_value_above_range()
+ {
+ request()->merge([
+ 'views' => '200',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Between(min: 1, max: 100)]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // Filter should be skipped
+ $this->assertStringNotContainsString('"views" =', $sql);
+ }
+
+ public function test_between_attribute_skips_filter_for_non_numeric_value()
+ {
+ request()->merge([
+ 'views' => 'abc',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Between(min: 1, max: 100)]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // Filter should be skipped
+ $this->assertStringNotContainsString('"views" =', $sql);
+ }
+
+ public function test_between_attribute_works_with_float_values()
+ {
+ request()->merge([
+ 'views' => '3.5',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Between(min: 1.0, max: 5.0)]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ $this->assertStringContainsString('3.5', $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/CastAttributeTest.php b/tests/Feature/Engines/Attributes/CastAttributeTest.php
new file mode 100644
index 0000000..a67fcc9
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/CastAttributeTest.php
@@ -0,0 +1,243 @@
+merge([
+ 'views' => '42',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Cast('int')]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('where "views" = 42', $sql);
+ }
+
+ public function test_cast_attribute_casts_value_to_boolean_true()
+ {
+ request()->merge([
+ 'is_featured' => 'true',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['is_featured'];
+
+ #[Cast('boolean')]
+ public function isFeatured(Payload $payload)
+ {
+ $this->builder->where('is_featured', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"is_featured" = 1', $sql);
+ }
+
+ public function test_cast_attribute_casts_value_to_boolean_false()
+ {
+ request()->merge([
+ 'is_featured' => 'false',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['is_featured'];
+
+ #[Cast('boolean')]
+ public function isFeatured(Payload $payload)
+ {
+ $this->builder->where('is_featured', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"is_featured"', $sql);
+ }
+
+ public function test_cast_attribute_casts_value_to_array_from_json()
+ {
+ request()->merge([
+ 'tags' => '["php","laravel"]',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['tags'];
+
+ #[Cast('array')]
+ public function tags(Payload $payload)
+ {
+ $this->builder->whereIn('tags', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"tags" in', $sql);
+ $this->assertStringContainsString('php', $sql);
+ $this->assertStringContainsString('laravel', $sql);
+ }
+
+ public function test_cast_attribute_throws_strictness_exception_for_unsupported_type()
+ {
+ $this->expectException(StrictnessException::class);
+ $this->expectExceptionMessage('Cast type [unsupported] is not supported.');
+
+ request()->merge([
+ 'status' => 'active',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Cast('unsupported')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ Post::filter($class)->toRawSql();
+ }
+
+ public function test_cast_attribute_does_not_throw_for_valid_cast_type()
+ {
+ request()->merge([
+ 'views' => '100',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Cast('int')]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '>', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('where "views" > 100', $sql);
+ }
+
+ public function test_cast_attribute_with_slug_type()
+ {
+ request()->merge([
+ 'title' => 'Hello World Post',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Cast('slug')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('hello-world-post', $sql);
+ }
+
+ public function test_cast_attribute_with_like_type()
+ {
+ request()->merge([
+ 'title' => 'Laravel',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Cast('like')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', 'LIKE', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('%Laravel%', $sql);
+ }
+
+ public function test_cast_attribute_with_empty_value_for_int_returns_null()
+ {
+ request()->merge([
+ 'views' => '',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Cast('int')]
+ public function views(Payload $payload)
+ {
+ if (!is_null($payload->value)) {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // Empty non-numeric value should produce null from asInt(), so no where clause added
+ $this->assertStringNotContainsString('where "views"', $sql);
+ }
+
+ public function test_cast_attribute_stage_is_transform()
+ {
+ $this->assertEquals(
+ \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value,
+ Cast::stage()
+ );
+ }
+
+ public function test_cast_attribute_handle_method_directly()
+ {
+ $payload = Payload::create('views', '=', '42', '42');
+ $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext(
+ payload: $payload
+ );
+
+ $cast = new Cast('int');
+ $cast->handle($context);
+
+ // handle doesn't throw, the cast is valid
+ $this->assertTrue(true);
+ }
+
+ public function test_cast_attribute_handle_throws_for_invalid_type()
+ {
+ $this->expectException(StrictnessException::class);
+
+ $payload = Payload::create('status', '=', 'active', 'active');
+ $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext(
+ payload: $payload
+ );
+
+ $cast = new Cast('nonExistent');
+ $cast->handle($context);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php b/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php
new file mode 100644
index 0000000..05dba84
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php
@@ -0,0 +1,38 @@
+merge([
+ 'tags' => 'php,laravel,testing',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['tags'];
+
+ #[Explode(',')]
+ public function tags(Payload $payload)
+ {
+ $this->builder->whereIn('tags', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('where "tags" in', $sql);
+ $this->assertStringContainsString('php', $sql);
+ $this->assertStringContainsString('laravel', $sql);
+ $this->assertStringContainsString('testing', $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/InAttributeTest.php b/tests/Feature/Engines/Attributes/InAttributeTest.php
new file mode 100644
index 0000000..d94606c
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/InAttributeTest.php
@@ -0,0 +1,55 @@
+merge([
+ 'status' => 'allowedValue',
+ ]);
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[In('allowedValue', 'anotherAllowedValue')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('name', '=', $payload);
+ }
+ };
+
+ $sql = 'select * from "posts" where "name" = \'allowedValue\'';
+
+ $this->assertStringContainsString($sql, Post::filter($class)->toRawSql());
+ }
+
+ public function test_in_attribute_throws_exception_for_value_not_in_allowed_set()
+ {
+ request()->merge([
+ 'status' => 'stopped',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[In('pending', 'approved', 'rejected')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('name', '=', $payload);
+ }
+ };
+
+ $sql = 'select * from "posts"';
+
+ $this->assertStringContainsString($sql, Post::filter($class)->toRawSql());
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/MapValueAttributeTest.php b/tests/Feature/Engines/Attributes/MapValueAttributeTest.php
new file mode 100644
index 0000000..3547cb7
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/MapValueAttributeTest.php
@@ -0,0 +1,118 @@
+merge([
+ 'status' => 'active',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[MapValue(['active' => 1, 'inactive' => 0])]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"status" = 1', $sql);
+ }
+
+ public function test_map_value_attribute_maps_inactive_to_zero()
+ {
+ request()->merge([
+ 'status' => 'inactive',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[MapValue(['active' => 1, 'inactive' => 0])]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"status" = 0', $sql);
+ }
+
+ public function test_map_value_attribute_keeps_original_value_when_not_in_map_non_strict()
+ {
+ request()->merge([
+ 'status' => 'pending',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[MapValue(['active' => 1, 'inactive' => 0])]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"status\" = 'pending'", $sql);
+ }
+
+ public function test_map_value_attribute_skips_filter_in_strict_mode_when_not_in_map()
+ {
+ request()->merge([
+ 'status' => 'unknown',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[MapValue(['active' => 1, 'inactive' => 0], strict: true)]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // Filter should be skipped entirely, so no where clause
+ $this->assertStringNotContainsString('"status" =', $sql);
+ }
+
+ public function test_map_value_attribute_maps_string_to_string()
+ {
+ request()->merge([
+ 'status' => 'published',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[MapValue(['published' => 'live', 'draft' => 'hidden'])]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"status\" = 'live'", $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/RegexAttributeTest.php b/tests/Feature/Engines/Attributes/RegexAttributeTest.php
new file mode 100644
index 0000000..b79aff9
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/RegexAttributeTest.php
@@ -0,0 +1,161 @@
+merge([
+ 'status' => 'active',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Regex('/^[a-z]+$/')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"status\" = 'active'", $sql);
+ }
+
+ public function test_regex_attribute_skips_filter_when_value_does_not_match()
+ {
+ request()->merge([
+ 'status' => 'ACTIVE123',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Regex('/^[a-z]+$/')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // Filter should be skipped
+ $this->assertStringNotContainsString('"status" =', $sql);
+ }
+
+ public function test_regex_attribute_validates_email_pattern()
+ {
+ request()->merge([
+ 'title' => 'test@example.com',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'test@example.com'", $sql);
+ }
+
+ public function test_regex_attribute_skips_filter_for_invalid_email()
+ {
+ request()->merge([
+ 'title' => 'not-an-email',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringNotContainsString('"title" =', $sql);
+ }
+
+ public function test_regex_attribute_validates_numeric_pattern()
+ {
+ request()->merge([
+ 'views' => '12345',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Regex('/^\d+$/')]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ $this->assertStringContainsString('12345', $sql);
+ }
+
+ public function test_regex_attribute_skips_filter_for_non_numeric_value_with_numeric_pattern()
+ {
+ request()->merge([
+ 'views' => 'abc',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Regex('/^\d+$/')]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringNotContainsString('"views" =', $sql);
+ }
+
+ public function test_regex_attribute_validates_slug_pattern()
+ {
+ request()->merge([
+ 'title' => 'hello-world-post',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php
index 340602f..f2aaf8a 100644
--- a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php
+++ b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php
@@ -48,26 +48,4 @@ public function status(\Kettasoft\Filterable\Support\Payload $payload)
$this->assertStringContainsString($sql, \Kettasoft\Filterable\Tests\Models\Post::filter($class)->toRawSql());
}
-
- public function test_required_value_attribute_throws_exception_when_value_missing_with_custom_message()
- {
- $this->expectException(StrictnessException::class);
- $this->expectExceptionMessage("The 'status' parameter is mandatory.");
-
- request()->merge([
- 'status' => '',
- ]);
-
- $class = new class extends \Kettasoft\Filterable\Filterable {
- protected $filters = ['status'];
-
- #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required('The \'%s\' parameter is mandatory.')]
- public function status(\Kettasoft\Filterable\Support\Payload $payload)
- {
- $this->builder->where('name', '=', $payload);
- }
- };
-
- \Kettasoft\Filterable\Tests\Models\Post::filter($class);
- }
}
diff --git a/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php b/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php
new file mode 100644
index 0000000..b5ac581
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php
@@ -0,0 +1,159 @@
+merge([
+ 'status' => 'ACTIVE',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Sanitize('lowercase')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"status\" = 'active'", $sql);
+ }
+
+ public function test_sanitize_attribute_converts_to_uppercase()
+ {
+ request()->merge([
+ 'status' => 'active',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Sanitize('uppercase')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"status\" = 'ACTIVE'", $sql);
+ }
+
+ public function test_sanitize_attribute_applies_ucfirst()
+ {
+ request()->merge([
+ 'title' => 'hello world',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Sanitize('ucfirst')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'Hello world'", $sql);
+ }
+
+ public function test_sanitize_attribute_strips_html_tags()
+ {
+ request()->merge([
+ 'title' => 'hello world',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Sanitize('strip_tags')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'hello world'", $sql);
+ }
+
+ public function test_sanitize_attribute_applies_multiple_rules_in_order()
+ {
+ request()->merge([
+ 'status' => ' ACTIVE ',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Sanitize('trim', 'strip_tags', 'lowercase')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"status\" = 'active'", $sql);
+ }
+
+ public function test_sanitize_attribute_converts_to_slug()
+ {
+ request()->merge([
+ 'title' => 'Hello World Post',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Sanitize('slug')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql);
+ }
+
+ public function test_sanitize_attribute_does_not_affect_non_string_values()
+ {
+ request()->merge([
+ 'views' => '42',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Sanitize('lowercase')]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/ScopeAttributeTest.php b/tests/Feature/Engines/Attributes/ScopeAttributeTest.php
new file mode 100644
index 0000000..35e58ce
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/ScopeAttributeTest.php
@@ -0,0 +1,103 @@
+merge([
+ 'status' => 'active',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Scope('active')]
+ public function status(Payload $payload)
+ {
+ // The scope is already applied by the attribute.
+ // This method can add additional logic if needed.
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"status"', $sql);
+ $this->assertStringContainsString('active', $sql);
+ }
+
+ public function test_scope_attribute_applies_popular_scope_with_value()
+ {
+ request()->merge([
+ 'views' => '500',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Scope('popular')]
+ public function views(Payload $payload)
+ {
+ // Scope is applied by the attribute with the payload value.
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views" >=', $sql);
+ $this->assertStringContainsString('500', $sql);
+ }
+
+ public function test_scope_attribute_skips_filter_for_non_existent_scope()
+ {
+ request()->merge([
+ 'status' => 'active',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[Scope('nonExistentScope')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // The scope does not exist, so the filter should be skipped entirely
+ // because the InvalidArgumentException is caught by the engine's attempt handler.
+ $this->assertStringNotContainsString('"status" =', $sql);
+ }
+
+ public function test_scope_attribute_works_with_other_attributes()
+ {
+ request()->merge([
+ 'status' => ' active ',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim]
+ #[Scope('active')]
+ public function status(Payload $payload)
+ {
+ // Trim runs first (TRANSFORM stage), then Scope (BEHAVIOR stage).
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"status"', $sql);
+ $this->assertStringContainsString('active', $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php b/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php
new file mode 100644
index 0000000..b26be89
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php
@@ -0,0 +1,183 @@
+merge([
+ 'status' => '',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[SkipIf('empty')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringNotContainsString('"status" =', $sql);
+ }
+
+ public function test_skip_if_attribute_does_not_skip_when_value_is_not_empty()
+ {
+ request()->merge([
+ 'status' => 'active',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[SkipIf('empty')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"status\" = 'active'", $sql);
+ }
+
+ public function test_skip_if_attribute_skips_when_value_is_null()
+ {
+ request()->merge([
+ 'status' => null,
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[SkipIf('null')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringNotContainsString('"status" =', $sql);
+ }
+
+ public function test_skip_if_attribute_with_negation_skips_when_value_is_not_numeric()
+ {
+ request()->merge([
+ 'views' => 'abc',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[SkipIf('!numeric')]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ // Should skip because !numeric is true (value is not numeric)
+ $this->assertStringNotContainsString('"views" =', $sql);
+ }
+
+ public function test_skip_if_attribute_with_negation_does_not_skip_when_value_is_numeric()
+ {
+ request()->merge([
+ 'views' => '42',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[SkipIf('!numeric')]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ $this->assertStringContainsString('42', $sql);
+ }
+
+ public function test_skip_if_attribute_skips_when_value_is_empty_string()
+ {
+ request()->merge([
+ 'title' => ' ',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[SkipIf('emptyString')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringNotContainsString('"title" =', $sql);
+ }
+
+ public function test_skip_if_attribute_multiple_instances_on_same_method()
+ {
+ request()->merge([
+ 'status' => '',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[SkipIf('empty')]
+ #[SkipIf('emptyString')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringNotContainsString('"status" =', $sql);
+ }
+
+ public function test_skip_if_attribute_skips_when_value_is_boolean()
+ {
+ request()->merge([
+ 'status' => 'true',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['status'];
+
+ #[SkipIf('boolean')]
+ public function status(Payload $payload)
+ {
+ $this->builder->where('status', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringNotContainsString('"status" =', $sql);
+ }
+}
diff --git a/tests/Feature/Engines/Attributes/TrimAttributeTest.php b/tests/Feature/Engines/Attributes/TrimAttributeTest.php
new file mode 100644
index 0000000..aaad96a
--- /dev/null
+++ b/tests/Feature/Engines/Attributes/TrimAttributeTest.php
@@ -0,0 +1,117 @@
+merge([
+ 'title' => ' hello world ',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Trim]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'hello world'", $sql);
+ }
+
+ public function test_trim_attribute_trims_left_only()
+ {
+ request()->merge([
+ 'title' => ' hello world ',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Trim(side: 'left')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'hello world '", $sql);
+ }
+
+ public function test_trim_attribute_trims_right_only()
+ {
+ request()->merge([
+ 'title' => ' hello world ',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Trim(side: 'right')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = ' hello world'", $sql);
+ }
+
+ public function test_trim_attribute_trims_custom_characters()
+ {
+ request()->merge([
+ 'title' => '---hello world---',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['title'];
+
+ #[Trim(characters: '-')]
+ public function title(Payload $payload)
+ {
+ $this->builder->where('title', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString("\"title\" = 'hello world'", $sql);
+ }
+
+ public function test_trim_attribute_does_not_affect_non_string_values()
+ {
+ request()->merge([
+ 'views' => '42',
+ ]);
+
+ $class = new class extends Filterable {
+ protected $filters = ['views'];
+
+ #[Trim]
+ public function views(Payload $payload)
+ {
+ $this->builder->where('views', '=', $payload->value);
+ }
+ };
+
+ $sql = Post::filter($class)->toRawSql();
+
+ $this->assertStringContainsString('"views"', $sql);
+ }
+}
diff --git a/tests/Models/Post.php b/tests/Models/Post.php
index 87ce475..9578a79 100644
--- a/tests/Models/Post.php
+++ b/tests/Models/Post.php
@@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Model;
use Kettasoft\Filterable\Tests\Models\Tag;
use Kettasoft\Filterable\Traits\HasFilterable;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Kettasoft\Filterable\Tests\Database\Factories\PostFactory;
@@ -21,6 +22,16 @@ class Post extends Model
'tags' => 'array',
];
+ public function scopeActive(Builder $query, $value = null): Builder
+ {
+ return $query->where('status', $value ?? 'active');
+ }
+
+ public function scopePopular(Builder $query, $minViews = 100): Builder
+ {
+ return $query->where('views', '>=', $minViews);
+ }
+
public function tags(): HasMany
{
return $this->hasMany(Tag::class);