There is a problem in Livewire that has plagued me since the beginning. I thought I solved it a couple months ago. Turns out I didn’t, and now I don’t know what to do. SO, I’m turning to YOU to help me out. I’ll try to make the explanation of the problem as simple as it can be, but as thorough as it needs to be. Thank you in advance for spending your precious brain power on this.
The Problem In One, Small, Code Snippet
class ShowPost extends Livewire\Component
{
public $post;
public function mount(Post $post)
{
$this->post = $post;
}
...
}
This little code sample seams reasonable. It’s one of the first things someone new to Livewire might try to do. HOWEVER, it’s illegal and will throw an error.
Why is this? Here’s the short explanation:
Public properties are sent back and forth to the browser on every update AND the browser has to be able to interpret their contents. This means, all public property data MUST be JavaScript readable. PHP objects ARE NOT JavaScript readable.
Now, here’s the full explanation:
Consider the following Livewire example:
class Counter extends Livewire\Component
{
public $count = 1;
public function increment()
{
$this->count++;
}
...
}
When a user hits the imaginary plus (increment) button, an ajax request is fired off to the server that looks something like this:
URL: your-app.com/livewire/message
Payload: {
action: {
'type': 'callMethod',
'name': 'increment',
'params': [],
},
data: {
count: 1,
}
}
The server then does the following things:
- Creates a new fresh instance of your component class
- “Hydrates” that component with the data from the payload (SETS the public property values)
- Runs the “increment” method on the component (updating the count to 2)
- Renders the Blade view of the component
- “Dehydrates” the component (GETS the public property values)
- Sends it back to the browser
Here’s what the response payload looks like:
Payload: {
html: '<div>...</div>',
data: {
count: 2,
}
}
Livewire then uses a package called “morphdom” to intelegently update the current DOM to match the returned HTML. (The user will see the number increment.)
If the user clicks the increment button again, this process will rinse and repeat.
Ok, now that you basically understand exactly how Livewire works. Let’s illustrate the problem with this new knowledge.
Consider again the original problem snippet:
class ShowPost extends Livewire\Component
{
public $post;
public function mount(Post $post)
{
$this->post = $post;
}
...
}
Now, what would the data part of the payload we described earlier look like for THIS component.
Payload: {
post: ???
}
Maybe post
is just $post->toArray()
? And we could program Livewire to automatically turn this data back into an Eloquent model on every request.
Payload: {
post: { id: 1, title: 'Some Title' }
}
OR maybe we serialize the object using PHP’s serialize()
function.
Payload: {
post: "O:8:"App\Post":26:{s:11:"\0*\0fillable";a:1:{i:0;s:5:"title";}s:9:"\0*\0hidden";a:1:{i:0;s:10:"created_at";}s:13:"\0*\0connection";N;s:8:"\0*\0table";s:5:"todos";s:13:"\0*\0primaryKey";s:2:"id";s:10:"\0*\0keyType";s:3:"int";s:12:"incrementing";b:1;s:7:"\0*\0with";a:0:{}s:12:"\0*\0withCount";a:0:{}s:10:"\0*\0perPage";i:15;s:6:"exists";b:1;s:18:"wasRecentlyCreated";b:0;s:13:"\0*\0attributes";a:4:{s:2:"id";s:2:"39";s:5:"title";s:4:"yeah";s:10:"created_at";s:19:"2019-05-20 21:01:49";s:10:"updated_at";s:19:"2019-05-20 21:01:49";}s:11:"\0*\0original";a:4:{s:2:"id";s:2:"39";s:5:"title";s:4:"Some Title";s:10:"created_at";s:19:"2019-05-20 21:01:49";s:10:"updated_at";s:19:"2019-05-20 21:01:49";}s:10:"\0*\0changes";a:0:{}s:8:"\0*\0casts";a:0:{}s:8:"\0*\0dates";a:0:{}s:13:"\0*\0dateFormat";N;s:10:"\0*\0appends";a:0:{}s:19:"\0*\0dispatchesEvents";a:0:{}s:14:"\0*\0observables";a:0:{}s:12:"\0*\0relations";a:0:{}s:10:"\0*\0touches";a:0:{}s:10:"timestamps";b:1;s:10:"\0*\0visible";a:0:{}s:10:"\0*\0guarded";a:1:{i:0;s:1:"*";}}"
}
That would make the data unreadable to JavaScript (for handy things like dirty
checking). But let’s forget about that, it also exposes server-side code to the browser, which is kind of a deal-breaker here.
Ok, so what if we encrypted it using Laravel’s encrypt()
function (which serializes, then encrypts it).
Payload: {
post: "eyJpdiI6InYraldHU1JlcDNZcTBXdElZSDVSa1E9PSIsInZhbHVlIjoiOHU4UVkzcm4zVHNUQWRrcUxnRTZxWWpwRUJ3MEZ0Mm1ZaVpEZHhaU2E0ZkNDdG53NlwvWE5QVW5XQVhRRytWTFlyZEZYQTdBMjExTDFZQSt6S1JcLzJoWURHazdsbGwyRzNFSlZjWXFSV1BXV3c5ODNFZlhaaEQzXC9KcGI4cEVDR0VldmQyS2hMdnY0aXg2cXE0d0tLdmVSaVFvUklDSnlndldkXC96M1l6YWFaVDVxVmNVNzlwWXlsNkNHTGlHOUtReEQxM090enRDK2swR3plWXM2a01SNXRJRU1kaGNMd1dpNHBrR1FmSWZ3UXM5Nkg3MHpUWHdROUFXYVwvWkRVQTI1UGd1dHRIUFZkXC9vOVRGdDlZMjcrSzZXcmYrVldxYU9NS1JpWTZqOFpSWDNEQnl6RjM0cVFGcmlsXC83K2hmWEZKcDBpSU00SW03SXhYb3VIemJuTlBSOFVJWEtmMVYyU1wvSWtkOEtkQXh0dnM4VkdmMmtGZ3VMdE5leGpDZVRQZnRLVFZcLzF0SGd2NzVvWU1ISEhPMFRCYWJRXC90SUVIU2JlbUk4RDVKeHhDaXdnbmF1OFk5RGM5a0pcL0I3dDhoSjVrTGNUbmVzWnJCV2pPZnJ2elIycG1FeHNvemhKeVwvN2UrbGhVXC9RaGZHSDBMcitBcWFNV015dlVTSjN1S1wvUHEwazlTRTVZS25DeFBCRks0Rk82NnhKMXAyblNidEJqc3pTNHMxd2JqeEpJbis2WkxDRWR6SUNjMlp4SytsOW1lK1kzbitVRE9qM1BWWXIxMXI5bWVXWkRPM1wvbmZXdmxZSXN3aXE3TWFLTXFFYmFJVzRyM0hJSEY4eTJhbFRLRHdhSWlYdXdRWlVHUW9TM2s4SmxKY1A3bG0rcGtNRmhsanFkdDNIazBPTDBEQ21uOHNcLzBXaVFuOW5kWHB3MERvNHh1cmZkVHJXTFFrdkhSd1BtVncyVVNMS0pFVEkyUll0NHhOUVJVbSs3ZHY1eHE4TUN4NW9mUnRsaXE5dmV2VWU3MHV6ZmNzQ01HVlwvMHV2TUZwcEFOVVFNcVdsbzhpYmNOaFZLMEcxMUFhM1M2NkdcL2xGZElOUWxxakozQ2dWNFhJNUQxSnoyTTBhSVNKcGM3SjZvQnozNVh3UmNSRVFBYkJcL0s4UHQxOUZvMDI1VEc5Wnozb1FmMFwvdDJ0Z2FXRGhib3hrQ0V3cExhc21NRVpneFJSc3hGSTZvSk5FN3ZOdGk3RUxQUjlNTnlsYk5ZY3oyYm43bGpzc1pRT0RCQ0k2c0dkTG5uVFNGVGZ1M29sTjN3cHVCQSt1Qkd6MTJcL3dKc2RWYlN1c25JUzFNODlCVStIM3RMQnRjK3JZS0paRCtxbnpxN1ZlZDlQRW9xVHREZXhLZXZueWcxaVBnQkVBVXYyUEJ5OEluMFFNM1RpTGhEMUVVYmhkN0RHWFdJd3pSWVdcL2ZyME1NRU42UGNxTnl2dGkzV25pWWZCMXBGdERwQkMyT3lxYXJyT29WcmFvT0VzYnQ4b0pFYzBpalJiUlRkaytBWndobXI4dXRPRlBOMlNJS3pOT1d0WU93dFdJYUdseGxId1JyTjc0RWZJNlpYNUV2TUFsSXY2YThDa0hENGx4WmVMM25JZFF4SmdVSHkwdzhJVk1ONEdzdUF1cVVOaWVjNlBwQ01nM0ZaamZpNlUrWFVpWjN5bGowWGl4UW14MTl5UEUzaW10NHdPekhqZ1pcL2thd0E9PSIsIm1hYyI6ImJkNzM1NjBlMjM2YjQxNWFjYWU3ODBhYmNhODcwNWJhZWViMmZmNGU0MjhhYTI2OTFkMThlOGIyNGVkMTI4NGUifQ=="
}
Ok, this solves the sensative backend data problem, but the payload is freaking HUGE. And this is just for the tiniest example model with one field called “title” and nothing else. Not to mention how slow encrypt()
ing is. We could try to make it faster, but we’d be compromising security by using less encryption rounds… ugh…
Ok, so, what if we solve all our problems by not sending the $post
data to the browser at all? What if there was some mechanism in Livewire that automatically took things like Eloquent models and stored them in the server-side Laravel cache between requests? This would be fast, secure.
Now that you understand the problem deeply, let’s take a look at a solution I implemented months ago and thought it was the silver bullet (spoiler alert: it’s not).
The Initial Solution In One, Small, Code Snippet
class ShowPost extends Livewire\Component
{
protected $post;
public function mount(Post $post)
{
$this->post = $post;
}
...
}
Notice the difference? The $post
property is protected
.
Remeber the flow I described before about what Livewire does everytime an update is made to a component? Well here’s the new flow with the changes highlighted:
- Creates a new fresh instance of your component class
- “Hydrates” that component with the data from the payload (SETS the public property values)
- "Hydrates" that component with the data from the cache (SETS the protected property values)
- Runs the “increment” method on the component (updating the count to 2)
- Renders the Blade view of the component
- “Dehydrates” the component (GETS the public property values)
- "Dehydrates" the component (Stores the protected property values in Laravel’s cache)
- Sends it back to the browser
Nice right?
Well there’s 2 issues with this approach. One tiny and solveable, and one MASSIVE and unsolveable (or so I think).
Issue #1
If each person using your app uses a component that stores properties in the cache. Won’t the cache get pretty darn big pretty quick?
Why yes, it will.
However, the solution’s pretty simple. I made a little garbage collection system, that keeps track of when a user navigates away from a page, and let’s the server know which components were on that page and aren’t being used anymore. The server then clears the cache for unused components.
Nifty right?
Well, this leads me to issue #2 (the BIG one):
Issue #2
Scenario: A user is using your Livewire component with protected properties on a page (all is well with the world). Now, they navigate away from the page (all is still well, AND like I said earlier Livewire will detect the navigation and clear the old cache).
The Problem: WHAT HAPPENS IF THEY HIT THE BACK BUTTON!!!
Well, they would get some error, because Livewire is like… “Hey, what happened to those protected properties I put in the cache?”
Ok, so this isn’t THAT big of a deal, we should just adjust the garbage collector to be less agressive and give a grace period for a user to navigate back to a page.
Cool cool cool…
Wait, but when the user navigates back now, the public data will be fresh from the page load, and the protected properties will be left in the state when the user navigated away…
If you didn’t follow this, here’s a demonstration of the problem:
Consider the following component:
class WhackyCounter extends Livewire\Component
{
public $publicCount = 1;
protected $protectedCount = 1;
public function increment()
{
$this->publicCount++;
$this->protectedCount++;
}
...
}
Let’s say this component just shows the user 2 numbers and an increment button.
Here’s the problem scenerio:
- User sees two numbers: 1 & 1
- User clicks the “increment” button
- User sees two different numbers: 2 & 2
- User navigates away to a new page
- User hits the back button
- User sees two numbers 1 & 1
- User clicks the “increment” button
- User sees two numbers: 2 & 3
- WHATTTT??? 2 AND 3??? WHY???
Here’s the reason this behavior is so whacky:
When you hit the back button in a browser, it doesn’t reload the previous page. It just uses the cache it has of the initial HTML state.
That’s why the counter resets to 1 & 1.
The issue arrises on the first round-trip to the server. The backend get’s the public 1 data from the request payload, but the protected 2 data from the old server cache.
There is a data mismatch.
The mismatch is caused because public and protected properties are fundamentally different in nature.
Public properties are stateless (They get passed around, and there can be copies of them), and protected properties are stateFULL (there is only one reference to them that lives in a real place on a server).
There are some big advantages to having a completely stateless Livewire. (pre-fetching, time-travelling in a debugging tool, etc…)
I know I’m throwing a lot at you. Forget about any fancy words I used. Let’s keep moving forward:
What The Hell Do We Do Now? Why Is Life So Hard?
Assuming Livewire should support the browser’s back button (hehe), here are the potential solutions as I see them:
A) “Punt”: Punt on the hole thing and tell Livewire users to go take a hike.
- For real though, this might be the best thing. Users would have to set the id of a model they wanted to use as a public property and get it back out of the database anytime they needed it in their component. Like this:
-
public $postId = 1; public function mount(Post $post) { $this->postId = $post->id; } public function someAction() { Post::find($this->postId)->doSomething(); }
B) “Model Casting”: Implement a “Data Casting” system that would look like this:
-
public $post; protected $casts = ['post' => 'model'];
Livewire would call
$post->toArray()
to dehydrate the model, andnewFromBuilder($data)
to re-hydrate the model back into an eloquent model.This solution is the most interesting to me because it would be user-friendly, but I have concerns about data-visibility, payload-size, and other odd things that would come from re-hydrating an eloquent model.
C) “Computed Props”: Build a “computed property” system and make that the idiomatic way to deal with models:
-
public $postId = 1; public function mount(Post $post) { $this->postId = $post->id; } public function getPostAttribute() { return Post::find($this->postId); }
The user could then access the post in the component with
$this->post
OR in the Blade view as just$post
.I also like this solution. It WOULD add a database query for every request, but a
::find
is generally pretty cheap. It also is less user friendly than other solutions.
D) “Bespoke Caching”: Offer a “bespoke caching” utility in components to store anything you want in a cache that is scoped to an individual component instance (like protected properties currently are)
-
public function mount(Post $post) { $this->cache('post', $post); } public function getPostAttribute() { return $this->cache('post'); }
The user could then access the post in the component with
$this->post
OR in the Blade view as just$post
, just like the previous solution. EXCEPT, now there is no DB query every request. HOWEVER, this would be behave exactly like the current protected properties solution and would therefore inherit the problems involving mixing stateFULL and stateLESS data (data drift on the back button click, etc…)I will likely add this caching feature anyways because it would be useful for other things, I just may not recomend it as the idiomatic way.
You Made It! Now TELL ME WHAT TO DO!
Phew, that was a long one. Thanks for following along and understanding every word I said and their deepest implications. You are a champ!
I’m so deep into this problem I’m having trouble knowing the best way forward.
What do you think? What would work best for your use cases?
What would you prioritize? (user friendliness, data obscurity, performance)
What would work for your projects? (Maybe you don’t even need to store models and other PHP-objecty stuff)
Thanks a bunch and ton!
- Caleb