@@ -405,10 +405,36 @@ protected function eagerLoadPlan($projection)
405405 return $ plan ;
406406 }
407407
408+ /**
409+ * Strip eager-load helper keys (`<fk>_object`) from a response row (KYTE-#190).
410+ * Model::eagerLoadRelations attaches the fetched relation as a dynamic
411+ * `<fk>_object` property for getObject() to consume — it is an internal
412+ * expansion helper, not a response field. getAllParams() surfaces it, so
413+ * left in the payload it duplicates the related row and, for relations whose
414+ * columns carry binary data (e.g. bzip2-compressed section templates),
415+ * produces invalid UTF-8 that makes json_encode fail. Only non-struct keys
416+ * ending in `_object` are removed. Pure/static for unit testing.
417+ *
418+ * @param array<string,mixed> $response Row from getAllParams()
419+ * @param array<string,mixed> $struct Model struct
420+ * @return array<string,mixed>
421+ */
422+ public static function stripEagerHelpers (array $ response , $ struct )
423+ {
424+ foreach (array_keys ($ response ) as $ rk ) {
425+ if (is_string ($ rk ) && substr ($ rk , -7 ) === '_object ' && !isset ($ struct [$ rk ])) {
426+ unset($ response [$ rk ]);
427+ }
428+ }
429+ return $ response ;
430+ }
431+
408432 protected function getObject ($ obj ) {
409433 try {
410- $ response = $ obj ->getAllParams ();
411-
434+ // Strip eager-load `<fk>_object` helpers; the object property itself
435+ // is kept so FK expansion below still uses the eager-loaded object.
436+ $ response = self ::stripEagerHelpers ($ obj ->getAllParams (), $ obj ->kyte_model ['struct ' ]);
437+
412438 foreach ($ response as $ key => &$ value ) {
413439 if (!isset ($ obj ->kyte_model ['struct ' ][$ key ])) {
414440 continue ; // Skip if key not found in struct
0 commit comments