Skip to content

Commit b0d3be4

Browse files
committed
Omit api-hidden custom fields from model toArray
1 parent fa16f6a commit b0d3be4

4 files changed

Lines changed: 208 additions & 7 deletions

File tree

packages/builder/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ Each field and field group can be toggled per context:
701701
| Context | Key | Wired at runtime |
702702
|---------|-----|------------------|
703703
| Admin | `visible_admin` | Yes — `HasCustomFields`, `saveFromFormData` |
704-
| API | `visible_api` | Yes — `MergesCustomFields` |
704+
| API | `visible_api` | Yes — `MergesCustomFields`, `$model->toArray()` |
705705
| Frontend | `visible_frontend` | Configurable in admin; **no packaged frontend renderer yet** |
706706

707707
Use `CustomFieldsManager::visibleFieldsForEntity($entity, FieldVisibility::API)` (or `ADMIN`) when building custom consumers.
@@ -888,7 +888,7 @@ $item->customFields(fresh: true); // reload from DB, ignore cache
888888
$item->customField('vehicle-type'); // single field
889889
$item->hasCustomField('color'); // value present (including default)?
890890
$item->hasCustomFieldDefinition('color'); // field definition exists?
891-
$item->toArray(); // DB columns + raw custom fields (internal)
891+
$item->toArray(); // DB columns + custom fields visible in API
892892

893893
// Write
894894
$item->color = 'Blue'; // or setCustomField()
@@ -968,10 +968,12 @@ return ItemApiResource::collection($items);
968968

969969
| Method | Use case | Example: `date` | Example: `password` | Example: `image` |
970970
|--------|----------|-----------------|---------------------|-------------------|
971-
| `$item->customFields()` | Internal / PHP | `Carbon` instance | hashed string | snapshot array |
972-
| `$item->toArray()` | Internal arrays | `Carbon` instance | `null` | snapshot array |
971+
| `$item->customFields()` | Internal / PHP | `Carbon` instance | hashed string / `StoredPassword` | snapshot array |
972+
| `$item->toArray()` | Arrays / accidental JSON | `Carbon` instance | `null` | snapshot array |
973973
| `mergeCustomFields()` | API / JSON | `"2026-06-16"` | `{"has_value": true}` | `MediaItemResource` shape |
974974

975+
`$item->toArray()` and `mergeCustomFields()` both respect `visible_api` (and nested keys). Prefer `MergesCustomFields` for real APIs so dates/media/passwords get the presented shapes. Use `customFields()` when PHP code needs every stored value including API-hidden fields.
976+
975977
### Output shapes (API)
976978

977979
| Field type | API output |
@@ -993,12 +995,13 @@ return ItemApiResource::collection($items);
993995
```php
994996
use Moox\Builder\Services\BuilderValuesResolver;
995997
use Moox\Builder\Services\CustomFieldsManager;
998+
use Moox\Builder\Support\FieldVisibility;
996999

9971000
$manager = app(CustomFieldsManager::class);
9981001
$entity = $item::resolveCustomFieldsEntity();
9991002

10001003
$presented = app(BuilderValuesResolver::class)->present(
1001-
$manager->fieldsForEntity($entity),
1004+
$manager->visibleFieldsForEntity($entity, FieldVisibility::API),
10021005
$item->customFields(),
10031006
);
10041007
```
@@ -1142,7 +1145,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc
11421145
- Nested validation (`FieldValueValidator`) plus admin validation UI (curated rules + `validation.raw_rules`)
11431146
- `InteractsWithCustomFields` on consumer models (`customFields()`, attribute access, queries, eager load)
11441147
- `CustomFieldsBuilder``where` / `whereIn` on custom field names via typed subqueries on `builder_field_values`
1145-
- `MergesCustomFields` for API resources (respects `visible_api`)
1148+
- `MergesCustomFields` / `$model->toArray()` for API-facing output (respect `visible_api`)
11461149
- `FieldType::presentValue()` for API serialization (ISO dates, password masking, nested fields, media, relations)
11471150
- Repeater min/max (`RepeaterItems` capability)
11481151
- Media fields: `image`, `gallery`, `file` (optional with `moox/media`)

packages/builder/src/Concerns/InteractsWithCustomFields.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Moox\Builder\Services\BuilderValuesResolver;
1717
use Moox\Builder\Services\CustomFieldsManager;
1818
use Moox\Builder\Support\BuilderLocaleResolver;
19+
use Moox\Builder\Support\FieldVisibility;
1920

2021
/**
2122
* Expose custom field values as if they belong to the Eloquent model.
@@ -276,9 +277,18 @@ public function toArray(): array
276277
$array = parent::toArray();
277278

278279
if ($this->mergeCustomFieldsIntoArray) {
280+
$manager = app(CustomFieldsManager::class);
281+
$projected = app(BuilderValuesResolver::class)->project(
282+
$manager->visibleFieldsForEntity(
283+
static::resolveCustomFieldsEntity(),
284+
FieldVisibility::API,
285+
),
286+
$this->customFields(),
287+
);
288+
279289
$customFields = [];
280290

281-
foreach ($this->customFields() as $name => $value) {
291+
foreach ($projected as $name => $value) {
282292
$customFields[$name] = $value instanceof StoredPassword ? null : $value;
283293
}
284294

packages/builder/src/Services/BuilderValuesResolver.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,46 @@ public function presentFieldValue(FieldDefinition $field, mixed $value): mixed
120120
return $fieldType->presentValue($value, $field);
121121
}
122122

123+
/**
124+
* Keep stored values shaped by the given field definitions without API
125+
* presentation transforms. Use with context-filtered definitions (e.g.
126+
* visible_api) so model toArray() can omit hidden keys while staying raw.
127+
*
128+
* @param Collection<int, FieldDefinition> $fields
129+
* @param array<string, mixed> $values
130+
* @return array<string, mixed>
131+
*/
132+
public function project(Collection $fields, array $values): array
133+
{
134+
$projected = [];
135+
136+
foreach ($fields as $field) {
137+
if (! array_key_exists($field->name, $values)) {
138+
continue;
139+
}
140+
141+
$projected[$field->name] = $this->projectFieldValue($field, $values[$field->name]);
142+
}
143+
144+
return $projected;
145+
}
146+
147+
public function projectFieldValue(FieldDefinition $field, mixed $value): mixed
148+
{
149+
$fieldType = $this->fieldTypeRegistry->get($field->type);
150+
151+
if ($fieldType->hasSubFields() && is_array($value)) {
152+
return match ($field->type) {
153+
'repeater' => $this->projectRepeaterRows($field, $value),
154+
'flexible_content' => $this->projectFlexibleContentItems($field, $value),
155+
'clone' => $this->projectCompoundRow($this->clonedFieldGroupResolver->compoundChildren($field), $value),
156+
default => $this->projectCompoundRow($field->children, $value),
157+
};
158+
}
159+
160+
return $value;
161+
}
162+
123163
public function persistFieldValue(FieldDefinition $field, mixed $value): mixed
124164
{
125165
$fieldType = $this->fieldTypeRegistry->get($field->type);
@@ -250,6 +290,60 @@ protected function presentFlexibleContentItems(FieldDefinition $field, array $it
250290
}, $items));
251291
}
252292

293+
/**
294+
* @param Collection<int, FieldDefinition> $children
295+
* @param array<string, mixed> $row
296+
* @return array<string, mixed>
297+
*/
298+
protected function projectCompoundRow(Collection $children, array $row): array
299+
{
300+
$projected = [];
301+
302+
foreach ($children as $child) {
303+
if (! array_key_exists($child->name, $row)) {
304+
continue;
305+
}
306+
307+
$projected[$child->name] = $this->projectFieldValue($child, $row[$child->name]);
308+
}
309+
310+
return $projected;
311+
}
312+
313+
/**
314+
* @param array<int, array<string, mixed>> $rows
315+
* @return list<array<string, mixed>>
316+
*/
317+
protected function projectRepeaterRows(FieldDefinition $field, array $rows): array
318+
{
319+
return array_values(array_map(
320+
fn (array $row): array => $this->projectCompoundRow($field->children, $row),
321+
$rows,
322+
));
323+
}
324+
325+
/**
326+
* @param array<int, array<string, mixed>> $items
327+
* @return list<array<string, mixed>>
328+
*/
329+
protected function projectFlexibleContentItems(FieldDefinition $field, array $items): array
330+
{
331+
$layouts = $field->layouts()->keyBy('name');
332+
333+
return array_values(array_map(function (array $item) use ($layouts): array {
334+
$type = (string) ($item['type'] ?? '');
335+
$data = is_array($item['data'] ?? null) ? $item['data'] : [];
336+
$layout = $layouts->get($type);
337+
338+
return [
339+
'type' => $type,
340+
'data' => $layout !== null
341+
? $this->projectCompoundRow($layout->children, $data)
342+
: $data,
343+
];
344+
}, $items));
345+
}
346+
253347
/**
254348
* @param iterable<int, Model> $models
255349
*/

packages/builder/tests/Unit/InteractsWithCustomFieldsTest.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Moox\Builder\Models\FieldGroup;
1414
use Moox\Builder\Models\FieldValue;
1515
use Moox\Builder\Registry\DefinitionRegistry;
16+
use Moox\Builder\Services\FieldGroupPersistence;
1617
use Moox\Builder\Tests\Support\TestItem;
1718
use Moox\Builder\Tests\TestCase;
1819

@@ -116,6 +117,99 @@
116117
]);
117118
});
118119

120+
it('omits api-hidden custom fields from model toArray while keeping them in customFields', function (): void {
121+
$group = FieldGroup::query()->first();
122+
123+
Field::query()->create([
124+
'field_group_id' => $group->getKey(),
125+
'name' => 'internal_note',
126+
'label' => 'Internal note',
127+
'type' => 'text',
128+
'sort' => 10,
129+
'settings' => ['visible_api' => false],
130+
'validation' => ['required' => false, 'rules' => []],
131+
]);
132+
133+
Cache::forget(DefinitionRegistry::CACHE_KEY);
134+
TestItem::flushCustomFieldDefinitionCache();
135+
136+
$record = TestItem::query()->create(['title' => 'Demo']);
137+
$record->setCustomFields([
138+
'farbe' => 'Blau',
139+
'internal_note' => 'staff-only',
140+
]);
141+
142+
$array = $record->toArray();
143+
144+
expect($record->customField('internal_note'))->toBe('staff-only')
145+
->and($array)->toHaveKey('farbe')
146+
->and($array['farbe'])->toBe('Blau')
147+
->and($array)->not->toHaveKey('internal_note');
148+
});
149+
150+
it('omits api-hidden nested group keys from model toArray', function (): void {
151+
require_once __DIR__.'/../Support/TestItemResource.php';
152+
153+
FieldGroup::query()->delete();
154+
Cache::forget(DefinitionRegistry::CACHE_KEY);
155+
TestItem::flushCustomFieldDefinitionCache();
156+
157+
$group = FieldGroup::query()->create([
158+
'name' => 'Address group',
159+
'slug' => 'address-group',
160+
'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]],
161+
'active' => true,
162+
]);
163+
164+
app(FieldGroupPersistence::class)->sync($group, [
165+
'name' => 'Address group',
166+
'slug' => 'address-group',
167+
'active' => true,
168+
'sort' => 0,
169+
'target_entities' => ['item'],
170+
'fields' => [
171+
[
172+
'name' => 'address',
173+
'label' => 'Address',
174+
'type' => 'group',
175+
'children' => [
176+
[
177+
'name' => 'city',
178+
'label' => 'City',
179+
'type' => 'text',
180+
'required' => false,
181+
],
182+
[
183+
'name' => 'internal_note',
184+
'label' => 'Internal note',
185+
'type' => 'text',
186+
'required' => false,
187+
'settings' => ['visible_api' => false],
188+
],
189+
],
190+
],
191+
],
192+
]);
193+
194+
Cache::forget(DefinitionRegistry::CACHE_KEY);
195+
TestItem::flushCustomFieldDefinitionCache();
196+
197+
$record = TestItem::query()->create(['title' => 'Demo']);
198+
$record->setCustomFields([
199+
'address' => [
200+
'city' => 'Berlin',
201+
'internal_note' => 'staff-only',
202+
],
203+
]);
204+
205+
expect($record->customField('address'))->toMatchArray([
206+
'city' => 'Berlin',
207+
'internal_note' => 'staff-only',
208+
])->and($record->toArray()['address'])->toBe([
209+
'city' => 'Berlin',
210+
]);
211+
});
212+
119213
it('saves builder values through the model trait', function (): void {
120214
$record = TestItem::query()->create(['title' => 'Demo']);
121215

0 commit comments

Comments
 (0)