Problem/Motivation

Getting a Object of class ... could not be converted to string in Drupal\Component\Render\HtmlEscapedText->__construct().

Steps to reproduce

This is notoriously difficult to reproduce.

Proposed resolution

This stems from a isset($field_item->$property_name) && ($field_item->$property_name instanceof FieldableEntityInterface conditional branch in Drupal\token\Hook\TokenTokensHooks::fieldTokens().

Token module is using isset($field_item->entity) as a gate to detect entity reference items before recursing for chained tokens (e.g. [user:field_ref:entity:url]). However, on EntityReferenceItem this is not reliable because FieldItemBase::__isset() delegates to Typed Data and returns FALSE when the underlying DataReference target is NULL, even if a valid target_id is present and __get('entity') would lazily resolve a real entity object. This leads to false negatives in the entity branch and causes the fallback to return an object (e.g. a Node, Meda, etc), ultimately triggering “Object of class … could not be converted to string” during rendering.

In other words, isset() here is checking typed data target state, not whether the property can resolve to a usable entity. The condition should instead rely on the dereferenced value (e.g. $value instanceof FieldableEntityInterface) or explicitly detect entity reference field types, rather than gating on isset().

I cannot currently reproduce this in a clean installation, but debugging the instance where I am seeing this confirms that isset($field_item->entity) returns FALSE while get_class($field_item->entity) returns a valid entity class in the same request, due to the Typed Data semantics described above.

Additionally, the fallback branch in this same conditional tree, really ought to guarantee a scalar replacement and never return raw entity objects.

Remaining tasks

TBD

User interface changes

TBD

API changes

TBD

Data model changes

TBD

Issue fork token-3574391

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

jcandan created an issue. See original summary.

jcandan’s picture

i-trokhanenko’s picture

Status: Active » Needs review

I ran into the same issue on a Drupal 11 site.

The error I was getting:

Error: Object of class Drupal\domain\Entity\Domain could not be converted to string in Drupal\Component\Render\HtmlEscapedText->__construct() (line 31 of /code/web/core/lib/Drupal/Component/Render/HtmlEscapedText.php).
Backtrace
#0 /code/web/core/lib/Drupal/Core/Utility/Token.php(258): Drupal\Component\Render\HtmlEscapedText->__construct()
#1 /code/web/core/lib/Drupal/Core/Utility/Token.php(195): Drupal\Core\Utility\Token->doReplace()

Environment:

  • Drupal core 11.3.3
  • Domain 2.0.0-rc2
  • Token 8.x-1.17

After applying the proposed patch #3 the error was resolved for me.

@jcandan thanks for working on this!

freelock’s picture

Status: Needs review » Reviewed & tested by the community

Another way to trigger this issue is using the new Modeler API with ECA, and have any other module installed that provides a new entity type - in our case, "profile" module.

Without this fix, you get a whitescreen when trying to open an ECA model in the new modeler. The merge request fixes this issue.

dadderley’s picture

freelock
I seen to be running into the exact same problem.
Will applying the patch in #3 to the dev release of token fix this?

I created this issue, https://www.drupal.org/project/eca/issues/3581504, but the problem persists. When I try to add a new model its the old WSOD for me.

freelock’s picture

@dadderly give it a try -- this fix is needed for certain entity references when the entire token tree is regenerated -- but not just the reference fields, but certain combinations of data in them. If you're hitting one of them, this issue should prevent the whitescreen -- but it also might omit certain tokens you may want to use.

I also found #3581692: MaybeUrl lacks __toString(), causes fatal error when token system resolves entity reference URL tokens that is related to this -- if you apply that patch, some of these URLs then resolve correctly (if you're using Canvas) -- but this patch is still needed. If you apply this patch alone, you'll avoid the whitescreen but the URL in the token may be blank or omitted.

jcandan’s picture

Assigned: jcandan » Unassigned

My bad, forgot to unassign myself after completing the MR.

berdir’s picture

Status: Reviewed & tested by the community » Needs work

I'm fine with removing the isset() as it's not really needed for field item objects, but there must be something deeper going on, possibly with ECA.

if (isset($this->properties[$name])) {
  return $this->properties[$name]->getValue() !== NULL;
}

entity is a computed property, as such, it's always initialized to an object and therefore always calls getValue() on it, this identical to the code in __get():

if (isset($this->properties[$name])) {
      return $this->properties[$name]->getValue();
}

This is identical and there is no logical explanation why they would behave different.

The second part of the code seems to address something different, namely entity references that are entities but not fieldable entities. That is valid, but there might be a better way to address this.

We could widen the instanceof from FieldableEntityInterface to just EntityInterface, that will already call $entity->label() then. \Drupal\Core\Entity\EntityRepository::getTranslationFromContext() is a no-op for config entities, but it also doesn't hurt to call it.

Additionally, this will also support nested refernces for config entities if that entity type supports tokens.

that just leaves us with objects that aren't entities, I'm OK with adding a __toString check, could also check for https://www.php.net/stringable interface, this is implicit for any method that implements __toString(). For the fallback, I'd suggest doing a get_class() or so as value. This is weird and having _some_ output there could help debug using wrong tokens.

rclemings’s picture

I was having the same problem as #5 and MR120 fixes it, just to add another data point.

Drupal 11.3.5
ECA 3.1.0
BPMN_IO 3.0.5
Modeler API 1.1.1
Profile 8.x-1.14

Error: Object of class Drupal\profile\Entity\ProfileType could not be converted to string in Drupal\Component\Render\HtmlEscapedText->__construct() (line 31 of core/lib/Drupal/Component/Render/HtmlEscapedText.php).
Drupal\Core\Utility\Token->doReplace() (Line: 195)
Drupal\Core\Utility\Token->replace() (Line: 409)
Drupal\eca\Token\ContribToken->replace() (Line: 952)
Drupal\modeler_api\Api->prepareTokenDefinition() (Line: 956)
Drupal\modeler_api\Api->prepareTokenDefinition() (Line: 956)
Drupal\modeler_api\Api->prepareTokenDefinition() (Line: 917)
Drupal\modeler_api\Api->prepareGlobalTokens() (Line: 337)
Drupal\modeler_api\Api->edit() (Line: 190)
Drupal\modeler_api\Controller\ModelerApi->edit()
call_user_func_array() (Line: 123)
Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->{closure:Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber::wrapControllerExecutionInRenderContext():121}() (Line: 634)
Drupal\Core\Render\Renderer::{closure:Drupal\Core\Render\Renderer::executeInRenderContext():634}()
Fiber->resume() (Line: 649)
Drupal\Core\Render\Renderer->executeInRenderContext() (Line: 121)
Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->wrapControllerExecutionInRenderContext() (Line: 97)
Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber->{closure:Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber::onController():96}() (Line: 183)
Symfony\Component\HttpKernel\HttpKernel->handleRaw() (Line: 76)
Symfony\Component\HttpKernel\HttpKernel->handle() (Line: 53)
Drupal\Core\StackMiddleware\Session->handle() (Line: 48)
Drupal\Core\StackMiddleware\KernelPreHandle->handle() (Line: 28)
Drupal\Core\StackMiddleware\ContentLength->handle() (Line: 32)
Drupal\big_pipe\StackMiddleware\ContentLength->handle() (Line: 118)
Drupal\page_cache\StackMiddleware\PageCache->pass() (Line: 92)
Drupal\page_cache\StackMiddleware\PageCache->handle() (Line: 48)
Drupal\Core\StackMiddleware\ReverseProxyMiddleware->handle() (Line: 51)
Drupal\Core\StackMiddleware\NegotiationMiddleware->handle() (Line: 53)
Drupal\Core\StackMiddleware\AjaxPageState->handle() (Line: 54)
Drupal\Core\StackMiddleware\StackedHttpKernel->handle() (Line: 745)
Drupal\Core\DrupalKernel->handle() (Line: 19)

I'll flag @jurgenhaas about this in a Slack thread that I opened on this. Maybe he can help move things along here.

berdir’s picture

#10 confirms to me that this isn't about the isset but about non-fieldable target entity types, the second condition in that if statement. I provided instructions on what should be changed in my comment.

jurgenhaas’s picture

Well, the stack trace in #10 indicates that the modeler API is preparing global tokens and their values: Drupal\modeler_api\Api->prepareGlobalTokens() (Line: 337). What that does is to go through all the defined global tokens and go through them all, generating their values to show them in the token browser so that the non-technical user can see what they get when they use either of those.

It must be the case that one of the global tokens contains an incorrect token definition. The fact that the modeler API runs into this issue is just bad luck. The same would happen when a user were to use the declared token somewhere; the token replacement would later run into this as well.

From the comments above, there's probably something wrong with user profiles. To mitigate this, the token API should either sanitize the token declarations (probably unlikely to happen), or the token replacement should provide some safeguards. It looks like the MR is just doing that.

I don't think that the modeler API should prevent such a token from being called for replacement. The same could happen everywhere, so the real fix should happen on the replacement level.

berdir’s picture

I fully agree that this is a bug in token.module, I just think the fix isn't correct yet, that's all.

> What that does is to go through all the defined global tokens and go through them all, generating their values to show them in the token browser so that the non-technical user can see what they get when they use either of those.

FWIW, that sounds very expensive to build. Just as an idea, it might be worth exploring to have some kind of "click to show example" feature or like webform does, make the example column opt-in. that would also make you less suspectible to bugs like this one, so your module remains usable then. ( I have not actually looked at your UI and what it does)

jurgenhaas’s picture

@berdir agreed, I'll open follow-up issues in Modeler API for these suggestions.

Just FYI, the expense is not affecting the general operation of the site, this is only loaded when the modeler UI is opened by somebody who is about to manage ECA models. But still, if we can improve things for them, that's also a good thing.

ericgsmith’s picture

I am seeing this on a client project - and I do not think it is entity type related as suspected before - there is something problematic with isset on the entity property which is likely a php or core issue?

In our case its also a slightly unusual flow, we have a view embedded via insert view module, which shows an entity, which uses a ds token field - so a lot of modules in play that could be doing something odd. This is hard to debug but here is what I can observe.

I am using a token

[media:field_media_document:entity:extension]

When I debug on L1284 we are at a point where L1184 has returned false.

Adding some additional variables to check - we can see that even after we have got a value as $test_value = $field_item->$property_name; we are still seeing the result of isset as false.

And the value is a File entity, so the interface check is fine - but clearly isset is not behaving as expected.

Fibers are involved in the loading of the entity - could this somehow cause isset to misbehave?

For some reason if I try evaluate within the breakpoint $this->properties[$name]->getValue() inside the __isset method, the first time I run it I can't see output, but after a waiting a bit I can. I assume this is Fibre related.

Removing the isset from the check as per the MR works - but I did not need to apply any of the additional handling changes in the final else block for this scenario.

ericgsmith’s picture

Final bit of hacking around - I saw that the magic get method has some Fibre workaround in EntityReferenceItemBase - https://git.drupalcode.org/project/drupal/-/blob/main/core/lib/Drupal/Co... for https://github.com/php/php-src/issues/14983

While neither issues mention isset, I guess this could be related? I tried coping that method and to do the same checks for isset, and it seems to largely fix things as far as I can tell - the check no longer fails and I get the expected code block at L1184. Breakpoint confirmed that \Fiber::getCurrent() returned true at the time isset is called by token.

So this looks like it could be cores responsibility to expand that safety check to cover isset? I've read the issues and really I can't understand it - its been a long week with a Drupal event taking up 3 days of it - so my brain is a bit fried. And like I mentioned - its not happening all the time, so it could that when tests with the isset workaround its just getting lucky.

Perhaps if somebody who was getting this on a FieldableEntityInterface type can try too? Then if it looks like it fixes it, I can open a core issue (or if somebody knows Fibers better and wants to open one - go ahead).

My assumption (I don't know the php internals) is that maybe if a similar fiber error does happen inside isset, that its swallowing the error / returning it as false? Who knows

The change I made locally was to \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItemBase to add:


  /**
   * {@inheritdoc}
   */
  public function __isset($property_name) {
    // Duplicate handling of __get in case this is the same error
    if ($property_name === 'entity' && \Fiber::getCurrent()) {
      $fiber = new \Fiber(fn() => parent::__isset($property_name));
      $fiber->start();
      while (!$fiber->isTerminated()) {
        if ($fiber->isSuspended()) {
          $resume_type = $fiber->resume();
          if (!$fiber->isTerminated() && $resume_type !== FiberResumeType::Immediate) {
            usleep(500);
          }
        }
      }
      return $fiber->getReturn();
    }
    return parent::__isset($property_name);
  }
berdir’s picture

Didn't read the whole comments yet, but FWIW, fibers should only come into play when the entity is not yet loaded/set. you wrote " we can see that even after we have got a value as $test_value = $field_item->$property_name; we are still seeing the result of isset as false."

The fiber bug that core works around shows itself as a "property entity does not exist" php warning, because it enters ->entity somewhere, gets suspended, then another fiber calls the same code path and then php doesn't enter the magic __get() anymore. If it would be a fiber pause, then the debugger would not proceed to the isset() line.

What you're seeing is certainly weird tough, maybe it's some other fiber related bug. I would instead of running that code inspect the state of the property object directly, you should see if that has a value (the entity) set or not. You can also try to rule out a fiber problem by commenting out the fiber suspend in \Drupal\Core\Entity\EntityStorageBase::loadMultiple. Does the problem still appear then?

ericgsmith’s picture

Thanks Sascha,

Further debugging from your suggestions - yes the entity is being loaded as a result of the isset.

With the changes discussed above removed (e.g. started unpatched clean core and token modules) I can see that prior to the isset being called the entity property is of \Drupal\Core\Entity\Plugin\DataType\EntityReference - it has an id property but no target / loaded entity.

When it reaches the "fallback" code block after the isset - I can see the same - there is no target / loaded entity set.

By removing the line $fiber->suspend(FiberResumeType::Immediate); from EntityStorageBase it does indeed resolve the issue. The page loads as expected and the token value is correct.

This time before the isset I observe the same, but the isset now returns TRUE and inside the expected code block I can see that the field item entity property does contain an EntityAdapter with the loaded entity as the target property.

If I put the Fiber suspend back in place, that is about as far as I can step through with xdebug - it seems to get confused at that point and jumps me to the Renderer. Not sure it thats xdebug being confused or just me being confused. So very much seems like something buggy happening with the use of Fibers inside the isset.

I've checked on php 8.3 and 8.4 and get the same behaviour

berdir’s picture

so this behavior almost makes sense to me, if it weren't for $test_value = $field_item->$property_name.

isset() being called while some other fiber is already within an isset() somewhere and then the isset() masks the property doesn't exist php warning that not going to into __get() results in. But the really weird part for me is that the line above works and returns a value but then the EntityReference typed data object not having a value? That shouldn't be possible?

Note that you're seeing a different use case than the original report, which is mostly about accessing this on config entity references. The change might still help you as we're bypassing the __isset() issue, but I'd suggest that you open a follow-up core issue to #3565937: Workaround PHP bug with fibers and __get() to apply the same there as you proposed.

The next step here is still that someone makes the recommended changes in #9, which will simplify this.

ericgsmith’s picture

I may have miscommunicated which point I was inspecting the values. In the fallback block, if I add back:

          $test_value = $field_item->$property_name;
          $test_isset = isset($field_item->$property_name);

When I pause at $test_isset then I do see a value with entity in the adapter - but isset is still returning FALSE.

What is really strange is that on the original check, I can step into the isset which takes me into the magic method - if I try step in again at $test_isset it doesn't step me in anywhere, just goes to the next line as FALSE.

ericgsmith’s picture

Thank you for the help debugging - I've added #3590537: Workaround PHP bug with fibers and __isset() - yes in hindsight I can see I've slightly hijacked this issue, my sense was that the original was describing this behaviour, as the fallback logic was described under "additionally" - but yes this would work around the issue by not relying on the isset. I'll take a look at the changes needed.

berdir’s picture

> When I pause at $test_isset then I do see a value with entity in the adapter - but isset is still returning FALSE.

Ah, so what I suspect happens is that some other fiber is still being suspended while being in the same isset(), then it skips over the magic method. You might be able to set a condition breakpoint within the __isset() (as it's probably called a lot) with a check on it being a FileItem + the entity property and maybe even a specific file id.

That's the common scenario with these fiber issues. One isset() is fine. But if you have an isset() on exactly this field item anywhere in a fiber, it gets suspended, and then another isset() calls it, it just skips it completely.

ericgsmith’s picture

Status: Needs work » Needs review

Thanks Sascha I'll give that a go - if I find any further info of note I'll likely move it over to #3590537: Workaround PHP bug with fibers and __isset()

I appreciate the help on this one - in an effort to get this original issue back on track I believe I have addressed the feedback in #9 and expanded test coverage for field tokens by adding a config entity reference field. Test only job shows original error https://git.drupalcode.org/issue/token-3574391/-/jobs/9852298#L160 - new test passes https://git.drupalcode.org/issue/token-3574391/-/jobs/9852293#L237

I believe this is ready for review again.

  • berdir committed 85ae736d on 8.x-1.x authored by jcandan
    fix: #3574391 Fix Object of class could not be converted to string in...
berdir’s picture

Status: Needs review » Fixed

Thanks, merged.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.