This course is still being released! Check back later for more chapters.

Get Notified About this Course!

We will send you messages regarding this course only
and nothing else, we promise.
You can unsubscribe anytime by emailing us at:
privacy@symfonycasts.com
Login to bookmark this video
Buy Access to Course
14.

Rendering LemonSqueezy Orders on the Account Page

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We've ordered a ton of digital lemonade lately, but we don't really have a convenient way to view those orders. Wouldn't it be cool if we could see a list of them on our account page? Now that we've established a relationship between the User entity and LemonSqueezy customer, we can!

LemonSqueezy API for fetching Orders

Start by opening src/Store/LemonSqueezyApi.php. Add a new method - public function listOrders() - and return an array.

101 lines | src/Store/LemonSqueezyApi.php
// ... lines 1 - 11
final readonly class LemonSqueezyApi
{
// ... lines 14 - 96
public function listOrders(): array
{
}
}

This function will fetch the orders from LemonSqueezy's API. If we head over to the LemonSqueezy docs, under "List all orders", we can see that we need to use a GET request to the /orders endpoint.

Back in our new method, add $response = $this->client->request(), and inside, Request::METHOD_GET to the orders path. Now, return $response->toArray().

104 lines | src/Store/LemonSqueezyApi.php
// ... lines 1 - 96
public function listOrders(): array
{
$response = $this->client->request(Request::METHOD_GET, 'orders');
return $response->toArray();
}
// ... lines 103 - 104

But wait... we don't want to display all orders - just the ones for our store and the current user - so we need to add some extra query parameters to filter this list.

Adding Filter Query Parameters

Let's add an empty array as the third argument to the request() method, and inside, write 'query' => [], 'filter' => [], 'store_id' => $this->storeId, and 'user_email' => $user->getEmail(). We also need to add User $user to the listOrders() method above. Perfect!

111 lines | src/Store/LemonSqueezyApi.php
// ... lines 1 - 96
public function listOrders(User $user): array
{
$response = $this->client->request(Request::METHOD_GET, 'orders', [
'query' => [
'filter' => [
'store_id' => $this->storeId,
'user_email' => $user->getEmail(),
],
],
]);
// ... lines 107 - 108
}
// ... lines 110 - 111

Next, open UserController.php. Down here, in account(), inject LemonSqueezyApi $api. We also need the current user, so add #[CurrentUser] User $user. Below, create the $orders variable and set it to $api->listOrders(). Finally, in the return, pass 'orders' => $orders.

63 lines | src/Controller/UserController.php
// ... lines 1 - 7
use App\Store\LemonSqueezyApi;
// ... lines 9 - 15
use Symfony\Component\Security\Http\Attribute\CurrentUser;
// ... line 17
class UserController extends AbstractController
{
// ... lines 20 - 53
public function account(LemonSqueezyApi $api, #[CurrentUser] User $user): Response
{
$orders = $api->listOrders($user);
return $this->render('user/account.html.twig', [
'orders' => $orders,
]);
}
}

Rendering Orders and Tailwind CSS Styling

Now we need to render those orders! Open the account.html.twig template... and somewhere below {{ app.user.email }}, paste this boilerplate code with some Tailwind CSS styling. You can copy this from the code blocks below the video.

59 lines | templates/user/account.html.twig
// ... lines 1 - 4
{% block content %}
// ... lines 6 - 9
{% if app.user.lsCustomerId %}
<div class="absolute"></div>
<div class="relative overflow-x-auto rounded-2xl border-2 border-[#4F272B] mt-7">
<table class="table-fixed w-full text-sm text-left rtl:text-right">
<thead class="poppins-bold uppercase">
<tr>
<th scope="col" class="px-6 py-3 border-b border-[#4F272B]">
Order
</th>
<th scope="col" class="px-6 py-3 border-b border-[#4F272B]">
Date
</th>
<th scope="col" class="px-6 py-3 border-b border-[#4F272B]">
Amount
</th>
<th scope="col" class="px-6 py-3 border-b border-[#4F272B]">
Action
</th>
</tr>
</thead>
<tbody>
{% for order in orders.data %}
<tr class="hover:bg-gray-50 text-lg poppins-regular">
<td class="px-6 py-4">
Order
</td>
<td class="px-6 py-4">
Date
</td>
<td class="px-6 py-4">
Amount
</td>
<td class="flex items-center px-6 py-4 text-sm">
<a href="#view" target="_blank" class="uppercase rounded-[60px] border-2 border-[#50272B] bg-white hover:bg-slate-100 shadow-inner poppins-bold py-1 px-4">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="rounded-2xl py-7 px-7 border-2 border-[#4F272B] w-[450px] mt-7 flex flex-col justify-center">
<p class="text-center mb-5 poppins-regular text-lg">No orders yet</p>
<div class="mt-3 mb-3 mx-auto"><a class="rounded-[60px] border-2 border-[#50272B] bg-[#FBD509] hover:bg-[#E2BC00] shadow-inner py-3 px-5 w-full poppins-bold uppercase" href="{{ path('app_homepage') }}">View Lemonade Options</a></div>
</div>
{% endif %}
// ... lines 56 - 57
{% endblock %}

Since we don't want everyone to see our orders, we need to render the list only if the app.user.lsCustomerId is set. If it is, an order table is displayed. If not, we'll just display a "No orders yet" message.

Let's see what we've got so far! Back on our site, refresh the page and... voila! Our orders list full of dummy data is visible! We'll need to replace this dummy data with real orders soon, but first, go to UserController::account() and dd() the $orders variable. Refresh again, and... there's our "data" with an array of orders.

Using Dynamic Data in Orders Table

If we click on "attributes", we see a ton of fields we can use for our order table. The first one I'll grab is order_number. In our code, replace Order with #{{ order.attributes.order_number }}. For Date, replace it with {{ order.attributes.created_at|date('d M Y, H:i') }}. We're using date filter here so the date will be easier to read. We have several options to choose from for the Amount field. I'll use total_formatted because it's pre-formatted by LemonSqueezy. Finally, for our link, we'll write {{ order.attributes.urls.receipt }}.

59 lines | templates/user/account.html.twig
// ... lines 1 - 4
{% block content %}
// ... lines 6 - 9
{% if app.user.lsCustomerId %}
// ... lines 11 - 12
<table class="table-fixed w-full text-sm text-left rtl:text-right">
// ... lines 14 - 29
<tbody>
{% for order in orders.data %}
<tr class="hover:bg-gray-50 text-lg poppins-regular">
<td class="px-6 py-4">
#{{ order.attributes.order_number }}
</td>
<td class="px-6 py-4">
{{ order.attributes.created_at|date('d M Y, H:i') }}
</td>
<td class="px-6 py-4">
{{ order.attributes.total_formatted }}
</td>
<td class="flex items-center px-6 py-4 text-sm">
<a href="{{ order.attributes.urls.receipt }}" target="_blank" class="uppercase rounded-[60px] border-2 border-[#50272B] bg-white hover:bg-slate-100 shadow-inner poppins-bold py-1 px-4">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
// ... lines 49 - 54
{% endif %}
// ... lines 56 - 57
{% endblock %}

Before we test this out, head back to UserController.php and remove the dd() we added earlier.

Paginate the Orders

Lemon Squeezy returns 10 orders by default. If you want to see fewer than ten at a time, we can paginate the list by adding 'page' => ['size' => 5] to the query. If we head back and refresh... we now have only the 5 latest orders displayed! It's working!

114 lines | src/Store/LemonSqueezyApi.php
// ... lines 1 - 11
final readonly class LemonSqueezyApi
{
// ... lines 14 - 96
public function listOrders(User $user): array
{
$response = $this->client->request(Request::METHOD_GET, 'orders', [
'query' => [
// ... lines 101 - 104
'page' => [
'size' => 5, // @see https://docs.lemonsqueezy.com/api/getting-started/requests#pagination
],
],
]);
// ... lines 110 - 111
}
}

Ideally, we should add real pagination below so users can navigate through all of their orders without leaving our site, but for now, let’s just add a link to the full order list on LemonSqueezy.

In the template, add a link, with the href: https://app.lemonsqueezy.com/my-orders/{{ (orders.data|first).attributes.identifier|default('') }}, target: _blank, and text: More Orders.

That link will take the user to LemonSqueezy and display all their orders - with the first order pre-selected.

But we don't need to see this link all the time - only if the user has more than five orders. Let's dd($orders) again, refresh, and inspect the data. In meta, page, we see total and perPage, so head back, remove the dd(), and wrap the link with {% if orders.meta.page.total > orders.meta.page.perPage %}. I'll fix this spacing and add {% endif %} at the end.

64 lines | templates/user/account.html.twig
// ... lines 1 - 4
{% block content %}
// ... lines 6 - 9
{% if app.user.lsCustomerId %}
// ... lines 11 - 49
{% if orders.meta.page.total > orders.meta.page.perPage %}
<p>
<a href="https://app.lemonsqueezy.com/my-orders/{{ (orders.data|first).attributes.identifier|default('') }}" target="_blank">More Orders</a>
</p>
{% endif %}
// ... lines 55 - 59
{% endif %}
// ... lines 61 - 62
{% endblock %}

Okay, if we refresh again and click the link... we see the details for the latest order, but... where are the others? This seems to currently be a LemonSqueezy limitation when in test mode. In production, this would also list all the customer's orders.

Preventing Leaks

Okay, now let's turn our attention to a small security issue here. At the moment, we're filtering orders by the email users have registered with our site. But, in theory, users could change their email to something they don't own. To mitigate this, we need to use the email set on the LemonSqueezy customer, not on our User entity. Inside LemonSqueezyApi.php, add a new public function and call it retrieveCustomer() with string $customerId, return array. Inside, write $response = $this->client->request(Request::METHOD_GET, 'customers/' . $customerId). Below, return $response->toArray().

121 lines | src/Store/LemonSqueezyApi.php
// ... lines 1 - 11
final readonly class LemonSqueezyApi
{
// ... lines 14 - 113
public function retrieveCustomer(string $customerId): array
{
$response = $this->client->request(Request::METHOD_GET, 'customers/' . $customerId);
return $response->toArray();
}
}

Above, in listOrders(), add $lsCustomerId = $user->getLsCustomerId(). Then, if (!$lsCustomerId), return []. This ensures there's no way a user can list orders if they don't have a LemonSqueezy customer ID.

127 lines | src/Store/LemonSqueezyApi.php
// ... lines 1 - 96
public function listOrders(User $user): array
{
$userEmail = $user->getEmail();
if ($user->getLsCustomerId()) {
$lsCustomer = $this->retrieveCustomer($user->getLsCustomerId());
$userEmail = $lsCustomer['data']['attributes']['email'];
}
// ... lines 104 - 117
}
// ... lines 119 - 127

Next, write $lsCustomer = $this->retrieveCustomer($lsCustomerId). Finally, change the user_email filter to $lsCustomer['data']['attributes']['email'].

127 lines | src/Store/LemonSqueezyApi.php
// ... lines 1 - 96
public function listOrders(User $user): array
{
// ... lines 99 - 104
$response = $this->client->request(Request::METHOD_GET, 'orders', [
'query' => [
'filter' => [
// ... line 108
'user_email' => $userEmail,
],
// ... lines 111 - 113
],
]);
// ... lines 116 - 117
}
// ... lines 119 - 127

Security hardened!

And that's all there is to it! You've successfully rendered a list of orders on the account page using the LemonSqueezy API.

Next: Let's make some improvements to our API error handling, because it's getting annoying to manually debug errors.