diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml
index 5325b3b..db07f91 100644
--- a/.github/workflows/php-tests.yml
+++ b/.github/workflows/php-tests.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- php-version: ["8.2", "8.3", "8.4"]
+ php-version: ["8.1", "8.2", "8.3", "8.4"]
fail-fast: false
steps:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61028a4..5443634 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,18 @@
+## v3.1.0 - Jun 26, 2026
+
+Added support for new content endpoints:
+
+- Breaking change: `emailMessages->update()` now has named parameters.
+- `uploads->upload()` for uploading images.
+- `transactional->create()`, `transactional->get()`, `transactional->update()`, `transactional->ensureDraft()`, and `transactional->publish()` for managing transactional emails.
+- `audienceSegments->list()` and `audienceSegments->get()` for audience segments.
+- `workflows->list()`, `workflows->get()`, and `workflows->getNode()` for reading workflows.
+- `campaignGroups->list()`, `campaignGroups->create()`, `campaignGroups->get()`, and `campaignGroups->update()` for campaign groups.
+- `transactionalGroups->list()`, `transactionalGroups->create()`, `transactionalGroups->get()`, and `transactionalGroups->update()` for transactional groups.
+- `emailMessages->preview()` for sending test email previews.
+- Extended `campaigns->create()` and `campaigns->update()` with audience, group, and scheduling fields.
+- Requires PHP 8.1.
+
## v3.0.0 - May 19, 2026
Added support for dedicated sending IPs, themes, components, campaigns, and email messages.
diff --git a/README.md b/README.md
index 42399b7..5f80839 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@ Install the Loops package [using Composer](https://packagist.org/packages/loops-
composer require loops-so/loops
```
+Requires PHP 8.1.
+
## Usage
You will need a Loops API key to use the package.
@@ -108,6 +110,11 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [events->send()](#events-send)
- [transactional->send()](#transactional-send)
- [transactional->list()](#transactional-list)
+- [transactional->create()](#transactional-create)
+- [transactional->get()](#transactional-get)
+- [transactional->update()](#transactional-update)
+- [transactional->ensureDraft()](#transactional-ensuredraft)
+- [transactional->publish()](#transactional-publish)
- [dedicatedSendingIps->list()](#dedicatedsendingips-list)
- [themes->list()](#themes-list)
- [themes->get()](#themes-get)
@@ -117,8 +124,23 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [campaigns->create()](#campaigns-create)
- [campaigns->get()](#campaigns-get)
- [campaigns->update()](#campaigns-update)
+- [campaignGroups->list()](#campaigngroups-list)
+- [campaignGroups->create()](#campaigngroups-create)
+- [campaignGroups->get()](#campaigngroups-get)
+- [campaignGroups->update()](#campaigngroups-update)
+- [audienceSegments->list()](#audiencesegments-list)
+- [audienceSegments->get()](#audiencesegments-get)
+- [workflows->list()](#workflows-list)
+- [workflows->get()](#workflows-get)
+- [workflows->getNode()](#workflows-getnode)
- [emailMessages->get()](#emailmessages-get)
- [emailMessages->update()](#emailmessages-update)
+- [emailMessages->preview()](#emailmessages-preview)
+- [transactionalGroups->list()](#transactionalgroups-list)
+- [transactionalGroups->create()](#transactionalgroups-create)
+- [transactionalGroups->get()](#transactionalgroups-get)
+- [transactionalGroups->update()](#transactionalgroups-update)
+- [uploads->upload()](#uploads-upload)
---
@@ -196,7 +218,7 @@ $result = $loops->contacts->create(
```json
{
"success": true,
- "id": "id_of_contact"
+ "id": "cll6b3i8901a9jx0oyktl2m4u"
}
```
@@ -261,7 +283,7 @@ $result = $loops->contacts->update(
```json
{
"success": true,
- "id": "id_of_contact"
+ "id": "cll6b3i8901a9jx0oyktl2m4u"
}
```
@@ -762,7 +784,7 @@ Send a transactional email to a contact. [Learn about sending transactional emai
| `$data_variables` | array | No | An array containing data as defined by the data variables added to the transactional email template.
Values can be of type `string` or `number`. |
| `$attachments` | array[] | No | A list of attachments objects.
**Please note**: Attachments need to be enabled on your account before using them with the API. [Read more](https://loops.so/docs/transactional/attachments) |
| `$attachments[]["filename"]` | string | No | The name of the file, shown in email clients. |
-| `$attachments[]["content_type"]` | string | No | The MIME type of the file. |
+| `$attachments[]["contentType"]` | string | No | The MIME type of the file. |
| `$attachments[]["data"]` | string | No | The base64-encoded content of the file. |
| `$headers` | array | No | Additional headers to send with the request. |
@@ -799,7 +821,7 @@ $result = $loops->transactional->send(
attachments: [
[
'filename' => 'presentation.pdf',
- 'content_type' => 'application/pdf',
+ 'contentType' => 'application/pdf',
'data' => base64_encode(file_get_contents('path/to/presentation.pdf'))
]
]
@@ -843,7 +865,7 @@ If there is a problem with the request, a descriptive error message will be retu
### transactional->list()
-Get a list of published transactional emails.
+Get a paginated list of transactional emails, most recently created first.
[API Reference](https://loops.so/docs/api-reference/list-transactional-emails)
@@ -872,24 +894,36 @@ $result = $loops->transactional->list(per_page: 15);
"perPage": 20,
"totalPages": 2,
"nextCursor": "clyo0q4wo01p59fsecyxqsh38",
- "nextPage": "https://app.loops.so/api/v1/transactional?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20"
+ "nextPage": "https://app.loops.so/api/v1/transactional-emails?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20"
},
"data": [
{
"id": "clfn0k1yg001imo0fdeqg30i8",
- "lastUpdated": "2023-11-06T17:48:07.249Z",
+ "name": "Welcome email",
+ "draftEmailMessageId": null,
+ "publishedEmailMessageId": "cly8k3m0n0044jpx2bghepq45",
+ "createdAt": "2023-11-06T17:48:07.249Z",
+ "updatedAt": "2023-11-06T17:48:07.249Z",
"dataVariables": []
},
{
"id": "cll42l54f20i1la0lfooe3z12",
- "lastUpdated": "2025-02-02T02:56:28.845Z",
+ "name": "Password reset",
+ "draftEmailMessageId": "cla3r8s9t0422ua56iqovab01",
+ "publishedEmailMessageId": "clb4s9t0u0533vb67jrpwbc12",
+ "createdAt": "2025-01-15T10:00:00.000Z",
+ "updatedAt": "2025-02-02T02:56:28.845Z",
"dataVariables": [
"confirmationUrl"
]
},
{
"id": "clw6rbuwp01rmeiyndm80155l",
- "lastUpdated": "2024-05-14T19:02:52.000Z",
+ "name": "Team invite",
+ "draftEmailMessageId": "clc5t0u1v0644wc78ksqxcd23",
+ "publishedEmailMessageId": null,
+ "createdAt": "2024-05-14T19:02:52.000Z",
+ "updatedAt": "2024-05-14T19:02:52.000Z",
"dataVariables": [
"firstName",
"lastName",
@@ -903,6 +937,161 @@ $result = $loops->transactional->list(per_page: 15);
---
+### transactional->create()
+
+Create a new transactional email. An empty draft email message is created automatically.
+
+[API Reference](https://loops.so/docs/api-reference/create-transactional-email)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `$name` | string | Yes | The name of the transactional email. |
+| `$transactional_group_id` | string | No | The ID of the group to add this transactional email to. Defaults to the team's default group when omitted. |
+
+#### Example
+
+```php
+$result = $loops->transactional->create(name: 'Welcome email');
+```
+
+#### Response
+
+```json
+{
+ "id": "clfq6dinn000yl70fgwwyp82l",
+ "name": "Welcome email",
+ "draftEmailMessageId": "cly8k3m0n0044jpx2bghepq45",
+ "draftEmailMessageContentRevisionId": "clm9n4o6p0088lrz4dijslt67",
+ "publishedEmailMessageId": null,
+ "createdAt": "2025-01-01T00:00:00.000Z",
+ "updatedAt": "2025-01-01T00:00:00.000Z",
+ "dataVariables": []
+}
+```
+
+---
+
+### transactional->get()
+
+Get a single transactional email by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-transactional-email)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------- | ------ | -------- | -------------------------------- |
+| `$transactional_id` | string | Yes | The ID of the transactional email. |
+
+#### Example
+
+```php
+$result = $loops->transactional->get(transactional_id: 'clfq6dinn000yl70fgwwyp82l');
+```
+
+---
+
+### transactional->update()
+
+Update a transactional email.
+
+At least one field alongside `transactional_id` must be provided.
+
+[API Reference](https://loops.so/docs/api-reference/update-transactional-email)
+
+#### Parameters
+
+
+| Name | Type | Required | Notes |
+| -------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------ |
+| `$transactional_id` | string | Yes | The ID of the transactional email. |
+| `$name` | string | No | The updated name. |
+| `$transactional_group_id` | string | No | The ID of the group to move this transactional email to. |
+
+#### Example
+
+```php
+$result = $loops->transactional->update(
+ transactional_id: 'clfq6dinn000yl70fgwwyp82l',
+ name: 'Updated welcome email'
+);
+```
+
+---
+
+### transactional->ensureDraft()
+
+Ensure a transactional email has a draft email message. If a draft already exists it is returned unchanged; otherwise a new empty draft is created.
+
+[API Reference](https://loops.so/docs/api-reference/ensure-transactional-email-draft)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------- | ------ | -------- | -------------------------------- |
+| `$transactional_id` | string | Yes | The ID of the transactional email. |
+
+#### Example
+
+```php
+$result = $loops->transactional->ensureDraft(transactional_id: 'clfq6dinn000yl70fgwwyp82l');
+```
+
+---
+
+### transactional->publish()
+
+Publish the transactional email's current draft email message.
+
+[API Reference](https://loops.so/docs/api-reference/publish-transactional-email)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------- | ------ | -------- | -------------------------------- |
+| `$transactional_id` | string | Yes | The ID of the transactional email. |
+
+#### Example
+
+```php
+$result = $loops->transactional->publish(transactional_id: 'clfq6dinn000yl70fgwwyp82l');
+```
+
+---
+
+### uploads->upload()
+
+Upload an image asset for use in LMX email content. The returned `finalUrl` can be used in an `` tag in your [LMX content](https://loops.so/docs/creating-emails/lmx).
+
+[API Reference](https://loops.so/docs/api-reference/create-upload)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- |
+| `$path` | string | Yes | Path to an image file. Supported types: JPEG, PNG, GIF, and WebP. Maximum file size is 4,000,000 bytes (4 MB). |
+
+#### Example
+
+```php
+$result = $loops->uploads->upload(path: '/path/to/image.png');
+
+$imageUrl = $result['finalUrl'];
+```
+
+#### Response
+
+```json
+{
+ "emailAssetId": "clu1v4w6x0254tz42lrcwat45",
+ "finalUrl": "https://cdn.example.com/image.png"
+}
+```
+
+---
+
### dedicatedSendingIps->list()
Get a list of Loops' dedicated sending IP addresses.
@@ -945,7 +1134,7 @@ List email themes.
```php
$result = $loops->themes->list();
-$result = $loops->themes->list(per_page: 15, cursor: 'cursor123');
+$result = $loops->themes->list(per_page: 15, cursor: 'clyo0q4wo01p59fsecyxqsh38');
```
---
@@ -965,7 +1154,7 @@ Get a single email theme by ID.
#### Example
```php
-$result = $loops->themes->get(theme_id: 'theme_abc123');
+$result = $loops->themes->get(theme_id: 'clo5p8q0r0132ntx6flkunw89');
```
---
@@ -1006,7 +1195,7 @@ Get a single email component by ID.
#### Example
```php
-$result = $loops->components->get(component_id: 'component_abc123');
+$result = $loops->components->get(component_id: 'clp6q9r1s0154ouy7gmlovx90');
```
---
@@ -1040,14 +1229,25 @@ Create a new draft campaign.
#### Parameters
-| Name | Type | Required | Notes |
-| ------- | ------ | -------- | ------------------ |
-| `$name` | string | Yes | The campaign name. |
+| Name | Type | Required | Notes |
+| ----------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- |
+| `$name` | string | Yes | The campaign name. |
+| `$campaign_group_id` | string | No | The ID of the group to add this campaign to. |
+| `$mailing_list_id` | string | No | The ID of the mailing list to send to. |
+| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this clears any `audience_filter`. |
+| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. |
+| `$scheduling` | array | No | When the campaign should send. Use `['method' => 'now']` or `['method' => 'schedule', 'timestamp' => '...']`. |
#### Example
```php
$result = $loops->campaigns->create(name: 'Spring announcement');
+
+$result = $loops->campaigns->create(
+ name: 'Spring announcement',
+ mailing_list_id: 'cm06f5v0e45nf0ml5754o9cix',
+ scheduling: ['method' => 'schedule', 'timestamp' => '2026-06-01T10:00:00Z']
+);
```
#### Response
@@ -1055,13 +1255,13 @@ $result = $loops->campaigns->create(name: 'Spring announcement');
```json
{
"success": true,
- "campaignId": "camp_123",
+ "campaignId": "cln4o7p9q0110msw5ekjtmv78",
"name": "Spring announcement",
"status": "Draft",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
- "emailMessageId": "msg_123",
- "emailMessageContentRevisionId": "rev_123"
+ "emailMessageId": "cly8k3m0n0044jpx2bghepq45",
+ "emailMessageContentRevisionId": "clm9n4o6p0088lrz4dijslt67"
}
```
@@ -1075,38 +1275,247 @@ Get a single campaign by ID.
#### Parameters
-| Name | Type | Required | Notes |
-| -------------- | ------ | -------- | --------------------- |
+| Name | Type | Required | Notes |
+| -------------- | ------ | -------- | ----------------------- |
| `$campaign_id` | string | Yes | The ID of the campaign. |
#### Example
```php
-$result = $loops->campaigns->get(campaign_id: 'camp_123');
+$result = $loops->campaigns->get(campaign_id: 'cln4o7p9q0110msw5ekjtmv78');
```
---
### campaigns->update()
-Update a draft campaign's name.
+Update a draft campaign's name, group, audience, or scheduling.
+
+At least one field alongside `campaign_id` must be provided.
[API Reference](https://loops.so/docs/api-reference/update-campaign)
#### Parameters
-| Name | Type | Required | Notes |
-| -------------- | ------ | -------- | --------------------- |
-| `$campaign_id` | string | Yes | The ID of the campaign. |
-| `$name` | string | Yes | The updated name. |
+| Name | Type | Required | Notes |
+| ----------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- |
+| `$campaign_id` | string | Yes | The ID of the campaign. |
+| `$name` | string | No | The updated name. |
+| `$campaign_group_id` | string | No | The ID of the group to move this campaign to. |
+| `$scheduling` | array | No | When the campaign should send. Use `['method' => 'now']` or `['method' => 'schedule', 'timestamp' => '...']`. |
+| `$mailing_list_id` | string | No | The ID of the mailing list to send to. Pass `null` to clear. |
+| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this clears any `audience_filter`. Pass `null` to clear. |
+| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. Pass `null` to clear. |
+
#### Example
```php
$result = $loops->campaigns->update(
- campaign_id: 'camp_123',
+ campaign_id: 'cln4o7p9q0110msw5ekjtmv78',
name: 'Updated name'
);
+
+// Clear the mailing list audience target
+$result = $loops->campaigns->update(
+ campaign_id: 'cln4o7p9q0110msw5ekjtmv78',
+ mailing_list_id: null
+);
+```
+
+---
+
+### campaignGroups->list()
+
+List campaign groups.
+
+[API Reference](https://loops.so/docs/api-reference/list-campaign-groups)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```php
+$result = $loops->campaignGroups->list();
+```
+
+---
+
+### campaignGroups->create()
+
+Create a campaign group.
+
+[API Reference](https://loops.so/docs/api-reference/create-campaign-group)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | ------ | -------- | --------------------------------------- |
+| `$name` | string | Yes | Cannot be the reserved name "Unsorted". |
+| `$description` | string | No | An optional description for the group. |
+
+#### Example
+
+```php
+$result = $loops->campaignGroups->create(name: 'Newsletters', description: 'Monthly updates');
+```
+
+---
+
+### campaignGroups->get()
+
+Get a campaign group by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-campaign-group)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----- | ------ | -------- | ---------------------------- |
+| `$campaign_group_id` | string | Yes | The ID of the campaign group. |
+
+#### Example
+
+```php
+$result = $loops->campaignGroups->get(campaign_group_id: 'clq7r0s2t0176pvz8hnmpwy01');
+```
+
+---
+
+### campaignGroups->update()
+
+Update a campaign group's name or description.
+
+At least one field alongside `campaign_group_id` must be provided.
+
+[API Reference](https://loops.so/docs/api-reference/update-campaign-group)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | ------ | -------- | --------------------------------------- |
+| `$campaign_group_id` | string | Yes | The ID of the campaign group. |
+| `$name` | string | No | Cannot be the reserved name "Unsorted". |
+| `$description` | string | No | |
+
+#### Example
+
+```php
+$result = $loops->campaignGroups->update(
+ campaign_group_id: 'clq7r0s2t0176pvz8hnmpwy01',
+ name: 'Updated name'
+);
+```
+
+---
+
+### audienceSegments->list()
+
+List audience segments.
+
+[API Reference](https://loops.so/docs/api-reference/list-audience-segments)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```php
+$result = $loops->audienceSegments->list();
+```
+
+---
+
+### audienceSegments->get()
+
+Get an audience segment by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-audience-segment)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----- | ------ | -------- | ------------------------------- |
+| `$audience_segment_id` | string | Yes | The ID of the audience segment. |
+
+#### Example
+
+```php
+$result = $loops->audienceSegments->get(audience_segment_id: 'clr8s1t3u0198qw09iotqzx12');
+```
+
+---
+
+### workflows->list()
+
+List workflows.
+
+[API Reference](https://loops.so/docs/api-reference/list-workflows)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```php
+$result = $loops->workflows->list();
+```
+
+---
+
+### workflows->get()
+
+Get a simplified workflow graph.
+
+[API Reference](https://loops.so/docs/api-reference/get-workflow)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----- | ------ | -------- | ---------------------- |
+| `$workflow_id` | string | Yes | The ID of the workflow. |
+
+#### Example
+
+```php
+$result = $loops->workflows->get(workflow_id: 'cls9t2u4v0210rx20jpuary23');
+```
+
+---
+
+### workflows->getNode()
+
+Get detailed data for a single workflow node.
+
+[API Reference](https://loops.so/docs/api-reference/get-workflow-node)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | ------ | -------- | ---------------------------- |
+| `$workflow_id` | string | Yes | The ID of the workflow. |
+| `$node_id` | string | Yes | The ID of the workflow node. |
+
+#### Example
+
+```php
+$result = $loops->workflows->getNode(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ node_id: 'clt0u3v5w0232sy31kqvbzs34'
+);
```
---
@@ -1119,42 +1528,181 @@ Get an email message, including its compiled LMX content.
#### Parameters
-| Name | Type | Required | Notes |
-| ------------------- | ------ | -------- | --------------------------- |
+| Name | Type | Required | Notes |
+| --------- | ------ | -------- | --------------------------- |
| `$email_message_id` | string | Yes | The ID of the email message. |
#### Example
```php
-$result = $loops->emailMessages->get(email_message_id: 'msg_123');
+$result = $loops->emailMessages->get(email_message_id: 'cly8k3m0n0044jpx2bghepq45');
```
---
### emailMessages->update()
-Update an email message's subject, preview text, sender, or LMX content.
+Update an email message.
+
+At least one field alongside `email_message_id` must be provided.
[API Reference](https://loops.so/docs/api-reference/update-email-message)
#### Parameters
-| Name | Type | Required | Notes |
-| ------------------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Name | Type | Required | Notes |
+| --------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `$email_message_id` | string | Yes | The ID of the email message. |
-| `$fields` | array | No | Fields to update. Use API field names: `expectedRevisionId`, `subject`, `previewText`, `fromName`, `fromEmail`, `replyToEmail`, `lmx`. Supply `expectedRevisionId` matching the current `contentRevisionId` to avoid 409 conflicts. |
+| `$expected_revision_id` | string | No | Supply a value matching the current `contentRevisionId` to avoid 409 conflicts. |
+| `$subject` | string | No | The email subject line. |
+| `$preview_text` | string | No | The email preview text. |
+| `$from_name` | string | No | The sender name. |
+| `$from_email` | string | No | The sender email address (the name before the `@`; your sending domain will be automatically appended). |
+| `$reply_to_email` | string | No | The reply-to email address. |
+| `$lmx` | string | No | The LMX content for the email message. |
+| `$contact_properties_fallbacks` | array | No | Contact property fallback values. Pass `null` as a value to remove an individual fallback entry. |
+| `$event_properties_fallbacks` | array | No | Event property fallback values. Pass `null` as a value to remove an individual fallback entry. |
+| `$data_variables_fallbacks` | array | No | Data variable fallback values. Pass `null` as a value to remove an individual fallback entry. |
+
#### Example
```php
$result = $loops->emailMessages->update(
- email_message_id: 'msg_123',
- fields: [
- 'expectedRevisionId' => 'rev_123',
- 'subject' => 'Updated subject',
- 'lmx' => 'Hello'
+ email_message_id: 'cly8k3m0n0044jpx2bghepq45',
+ expected_revision_id: 'clm9n4o6p0088lrz4dijslt67',
+ subject: 'Updated subject',
+ lmx: 'Hello'
+);
+
+// Example with contact property fallbacks
+$result = $loops->emailMessages->update(
+ email_message_id: 'cly8k3m0n0044jpx2bghepq45',
+ contact_properties_fallbacks: [
+ 'firstName' => 'there', // If firstName is missing, use "there"
+ 'company' => 'your company', // If company is missing, use "your company"
+ 'planName' => null // null removes the fallback for "planName"
]
);
+
+```
+
+---
+
+### emailMessages->preview()
+
+Send a test preview of an email message to one or more addresses.
+
+[API Reference](https://loops.so/docs/api-reference/send-email-message-preview)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ---------------------- | -------- | -------- | --------------------------------------------------------------------------------- |
+| `$email_message_id` | string | Yes | The ID of the email message. |
+| `$emails` | array | Yes | One or more addresses to send the preview to. |
+| `$contact_properties` | array | No | Contact property values to render. Accepted for campaign and workflow previews. |
+| `$event_properties` | array | No | Event property values to render. Accepted for workflow previews only. |
+| `$data_variables` | array | No | Transactional data variables to render. Accepted for transactional previews only. |
+
+#### Example
+
+```php
+$result = $loops->emailMessages->preview(
+ email_message_id: 'cly8k3m0n0044jpx2bghepq45',
+ emails: ['test@example.com'],
+ contact_properties: ['firstName' => 'Alex']
+);
+```
+
+---
+
+### transactionalGroups->list()
+
+List transactional groups.
+
+[API Reference](https://loops.so/docs/api-reference/list-transactional-groups)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```php
+$result = $loops->transactionalGroups->list();
+```
+
+---
+
+### transactionalGroups->create()
+
+Create a transactional group.
+
+[API Reference](https://loops.so/docs/api-reference/create-transactional-group)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | ------ | -------- | --------------------------------------- |
+| `$name` | string | Yes | Cannot be the reserved name "Unsorted". |
+| `$description` | string | No | An optional description for the group. |
+
+#### Example
+
+```php
+$result = $loops->transactionalGroups->create(name: 'Account emails');
+```
+
+---
+
+### transactionalGroups->get()
+
+Get a transactional group by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-transactional-group)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----- | ------ | -------- | --------------------------------- |
+| `$transactional_group_id` | string | Yes | The ID of the transactional group. |
+
+#### Example
+
+```php
+$result = $loops->transactionalGroups->get(transactional_group_id: 'clv2w3x4y0288xbb0kqrsuv67');
+```
+
+---
+
+### transactionalGroups->update()
+
+Update a transactional group's name or description.
+
+At least one field alongside `transactional_group_id` must be provided.
+
+[API Reference](https://loops.so/docs/api-reference/update-transactional-group)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | ------ | -------- | --------------------------------------- |
+| `$transactional_group_id` | string | Yes | The ID of the transactional group. |
+| `$name` | string | No | Cannot be the reserved name "Unsorted". |
+| `$description` | string | No | |
+
+
+#### Example
+
+```php
+$result = $loops->transactionalGroups->update(
+ transactional_group_id: 'clv2w3x4y0288xbb0kqrsuv67',
+ name: 'Updated name'
+);
```
---
diff --git a/composer.json b/composer.json
index 2c3e1d9..5c143e7 100644
--- a/composer.json
+++ b/composer.json
@@ -5,7 +5,7 @@
"type": "library",
"homepage": "https://github.com/Loops-so/loops-php",
"require": {
- "php": "^8.0",
+ "php": "^8.1",
"guzzlehttp/guzzle": "^7.9"
},
"autoload": {
diff --git a/src/AudienceSegments.php b/src/AudienceSegments.php
new file mode 100644
index 0000000..98afa9d
--- /dev/null
+++ b/src/AudienceSegments.php
@@ -0,0 +1,30 @@
+client = $client;
+ }
+
+ public function list(?int $per_page = null, ?string $cursor = null): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments', options: [
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
+ ]);
+ }
+
+ public function get(string $audience_segment_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments/' . $audience_segment_id);
+ }
+}
diff --git a/src/CampaignGroups.php b/src/CampaignGroups.php
new file mode 100644
index 0000000..d6f4766
--- /dev/null
+++ b/src/CampaignGroups.php
@@ -0,0 +1,56 @@
+client = $client;
+ }
+
+ public function list(?int $per_page = null, ?string $cursor = null): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups', options: [
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
+ ]);
+ }
+
+ public function create(string $name, ?string $description = null): mixed
+ {
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups', options: [
+ 'json' => Util::omitNull([
+ 'name' => $name,
+ 'description' => $description,
+ ])
+ ]);
+ }
+
+ public function get(string $campaign_group_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups/' . $campaign_group_id);
+ }
+
+ public function update(string $campaign_group_id, ?string $name = null, ?string $description = null): mixed
+ {
+ $payload = Util::omitNull([
+ 'name' => $name,
+ 'description' => $description,
+ ]);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one field must be provided.');
+ }
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups/' . $campaign_group_id, options: [
+ 'json' => $payload
+ ]);
+ }
+}
diff --git a/src/Campaigns.php b/src/Campaigns.php
index e4d863c..f59309e 100644
--- a/src/Campaigns.php
+++ b/src/Campaigns.php
@@ -15,23 +15,35 @@ public function __construct(LoopsClient $client)
public function list(?int $per_page = null, ?string $cursor = null): mixed
{
- $query = [];
- if ($per_page !== null) {
- $query['perPage'] = $per_page;
- }
- if ($cursor) {
- $query['cursor'] = $cursor;
- }
-
return $this->client->query(method: 'GET', endpoint: 'v1/campaigns', options: [
- 'query' => $query
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
]);
}
- public function create(string $name): mixed
- {
+ public function create(
+ string $name,
+ ?string $campaign_group_id = null,
+ ?string $mailing_list_id = null,
+ ?string $audience_segment_id = null,
+ ?array $audience_filter = null,
+ ?array $scheduling = null
+ ): mixed {
+ $payload = array_merge(
+ ['name' => $name],
+ Util::omitNull([
+ 'campaignGroupId' => $campaign_group_id,
+ 'mailingListId' => $mailing_list_id,
+ 'audienceSegmentId' => $audience_segment_id,
+ 'audienceFilter' => $audience_filter,
+ 'scheduling' => $scheduling,
+ ])
+ );
+
return $this->client->query(method: 'POST', endpoint: 'v1/campaigns', options: [
- 'json' => ['name' => $name]
+ 'json' => $payload
]);
}
@@ -40,10 +52,30 @@ public function get(string $campaign_id): mixed
return $this->client->query(method: 'GET', endpoint: 'v1/campaigns/' . $campaign_id);
}
- public function update(string $campaign_id, string $name): mixed
- {
+ public function update(
+ string $campaign_id,
+ string $name,
+ ?string $campaign_group_id = null,
+ mixed $mailing_list_id = Core::UNSET,
+ mixed $audience_segment_id = Core::UNSET,
+ mixed $audience_filter = Core::UNSET,
+ ?array $scheduling = null
+ ): mixed {
+ $payload = array_merge(
+ ['name' => $name],
+ Util::omitNull([
+ 'campaignGroupId' => $campaign_group_id,
+ 'scheduling' => $scheduling,
+ ]),
+ Util::omitUnset([
+ 'mailingListId' => $mailing_list_id,
+ 'audienceSegmentId' => $audience_segment_id,
+ 'audienceFilter' => $audience_filter,
+ ]),
+ );
+
return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $campaign_id, options: [
- 'json' => ['name' => $name]
+ 'json' => $payload
]);
}
}
diff --git a/src/Components.php b/src/Components.php
index 9f38afa..c1af4b6 100644
--- a/src/Components.php
+++ b/src/Components.php
@@ -15,16 +15,11 @@ public function __construct(LoopsClient $client)
public function list(?int $per_page = null, ?string $cursor = null): mixed
{
- $query = [];
- if ($per_page !== null) {
- $query['perPage'] = $per_page;
- }
- if ($cursor) {
- $query['cursor'] = $cursor;
- }
-
return $this->client->query(method: 'GET', endpoint: 'v1/components', options: [
- 'query' => $query
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
]);
}
diff --git a/src/ContactProperties.php b/src/ContactProperties.php
index eb45062..9e2ca35 100644
--- a/src/ContactProperties.php
+++ b/src/ContactProperties.php
@@ -26,12 +26,10 @@ public function create(string $name, string $type = 'string' | 'number' | 'boole
}
public function list(?string $list = null): mixed
{
- $query = [];
- if ($list) {
- $query['list'] = $list;
- }
return $this->client->query(method: 'GET', endpoint: 'v1/contacts/properties', options: [
- 'query' => $query
+ 'query' => Util::omitNull([
+ 'list' => $list,
+ ])
]);
}
}
\ No newline at end of file
diff --git a/src/Contacts.php b/src/Contacts.php
index ee04dff..b7e37b1 100644
--- a/src/Contacts.php
+++ b/src/Contacts.php
@@ -15,11 +15,13 @@ public function __construct(LoopsClient $client)
public function create(string $email, ?array $properties = [], ?array $mailing_lists = []): mixed
{
- $payload = [
- 'email' => $email,
- 'mailingLists' => $mailing_lists
- ];
- $payload = array_merge($payload, $properties);
+ $payload = array_merge(
+ [
+ 'email' => $email,
+ 'mailingLists' => $mailing_lists
+ ],
+ $properties
+ );
return $this->client->query(method: 'POST', endpoint: 'v1/contacts/create', options: [
'json' => $payload
@@ -31,12 +33,12 @@ public function update(?string $email = null, ?string $user_id = null, ?array $p
if (!$email && !$user_id) {
throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.');
}
- $payload = [
- 'email' => $email,
- 'userId' => $user_id,
- 'mailingLists' => $mailing_lists
- ];
- $payload = array_merge($payload, $properties);
+
+ $payload = array_merge(
+ Util::omitNull(['email' => $email, 'userId' => $user_id]),
+ ['mailingLists' => $mailing_lists],
+ $properties
+ );
return $this->client->query(method: 'PUT', endpoint: 'v1/contacts/update', options: [
'json' => $payload
@@ -51,14 +53,12 @@ public function find(?string $email = null, ?string $user_id = null): mixed
if (!$email && !$user_id) {
throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.');
}
- $query = [];
- if ($email)
- $query['email'] = $email;
- if ($user_id)
- $query['userId'] = $user_id;
return $this->client->query(method: 'GET', endpoint: 'v1/contacts/find', options: [
- 'query' => $query
+ 'query' => Util::omitNull([
+ 'email' => $email,
+ 'userId' => $user_id,
+ ])
]);
}
@@ -71,14 +71,11 @@ public function delete(?string $email = null, ?string $user_id = null): mixed
throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.');
}
- $payload = [];
- if ($email)
- $payload['email'] = $email;
- if ($user_id)
- $payload['userId'] = $user_id;
-
return $this->client->query(method: 'POST', endpoint: 'v1/contacts/delete', options: [
- 'json' => $payload
+ 'json' => Util::omitNull([
+ 'email' => $email,
+ 'userId' => $user_id,
+ ])
]);
}
@@ -91,14 +88,11 @@ public function checkSuppression(?string $email = null, ?string $user_id = null)
throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.');
}
- $query = [];
- if ($email)
- $query['email'] = $email;
- if ($user_id)
- $query['userId'] = $user_id;
-
return $this->client->query(method: 'GET', endpoint: 'v1/contacts/suppression', options: [
- 'query' => $query
+ 'query' => Util::omitNull([
+ 'email' => $email,
+ 'userId' => $user_id,
+ ])
]);
}
@@ -111,14 +105,11 @@ public function removeSuppression(?string $email = null, ?string $user_id = null
throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.');
}
- $query = [];
- if ($email)
- $query['email'] = $email;
- if ($user_id)
- $query['userId'] = $user_id;
-
return $this->client->query(method: 'DELETE', endpoint: 'v1/contacts/suppression', options: [
- 'query' => $query
+ 'query' => Util::omitNull([
+ 'email' => $email,
+ 'userId' => $user_id,
+ ])
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Core.php b/src/Core.php
new file mode 100644
index 0000000..5086fed
--- /dev/null
+++ b/src/Core.php
@@ -0,0 +1,8 @@
+client->query(method: 'GET', endpoint: 'v1/email-messages/' . $email_message_id);
}
- public function update(string $email_message_id, array $fields = []): mixed
- {
+ public function update(
+ string $email_message_id,
+ ?string $expected_revision_id = null,
+ ?string $subject = null,
+ ?string $preview_text = null,
+ ?string $from_name = null,
+ ?string $from_email = null,
+ ?string $reply_to_email = null,
+ ?string $lmx = null,
+ ?array $contact_properties_fallbacks = null,
+ ?array $event_properties_fallbacks = null,
+ ?array $data_variables_fallbacks = null
+ ): mixed {
+ $payload = Util::omitNull([
+ 'expectedRevisionId' => $expected_revision_id,
+ 'subject' => $subject,
+ 'previewText' => $preview_text,
+ 'fromName' => $from_name,
+ 'fromEmail' => $from_email,
+ 'replyToEmail' => $reply_to_email,
+ 'lmx' => $lmx,
+ 'contactPropertiesFallbacks' => $contact_properties_fallbacks,
+ 'eventPropertiesFallbacks' => $event_properties_fallbacks,
+ 'dataVariablesFallbacks' => $data_variables_fallbacks,
+ ]);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one field must be provided.');
+ }
+
return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $email_message_id, options: [
- 'json' => $fields
+ 'json' => $payload
+ ]);
+ }
+
+ public function preview(
+ string $email_message_id,
+ array $emails,
+ ?array $contact_properties = null,
+ ?array $event_properties = null,
+ ?array $data_variables = null
+ ): mixed {
+ $payload = array_merge(
+ ['emails' => $emails],
+ Util::omitNull([
+ 'contactProperties' => $contact_properties,
+ 'eventProperties' => $event_properties,
+ 'dataVariables' => $data_variables,
+ ])
+ );
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $email_message_id . '/preview', options: [
+ 'json' => $payload
]);
}
}
diff --git a/src/Events.php b/src/Events.php
index df99b2a..2dc069b 100644
--- a/src/Events.php
+++ b/src/Events.php
@@ -25,19 +25,22 @@ public function send(
throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.');
}
- $payload = [
- 'eventName' => $event_name,
- 'email' => $email,
- 'userId' => $user_id,
- 'eventProperties' => $event_properties,
- 'mailingLists' => $mailing_lists,
- ];
-
- $payload = array_merge($payload, $contact_properties);
+ $payload = array_merge(
+ [
+ 'eventName' => $event_name,
+ 'eventProperties' => $event_properties,
+ 'mailingLists' => $mailing_lists,
+ ],
+ Util::omitNull([
+ 'email' => $email,
+ 'userId' => $user_id,
+ ]),
+ $contact_properties
+ );
return $this->client->query(method: 'POST', endpoint: 'v1/events/send', options: [
'json' => $payload,
'headers' => $headers
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/LoopsClient.php b/src/LoopsClient.php
index 84c505b..b86fd18 100644
--- a/src/LoopsClient.php
+++ b/src/LoopsClient.php
@@ -7,6 +7,7 @@ class LoopsClient
private const BASE_URI = 'https://app.loops.so/api/';
private \GuzzleHttp\Client $httpClient;
+ private \GuzzleHttp\Client $uploadHttpClient;
public ApiKey $apiKey;
public Contacts $contacts;
public Events $events;
@@ -17,7 +18,12 @@ class LoopsClient
public Themes $themes;
public Components $components;
public Campaigns $campaigns;
+ public CampaignGroups $campaignGroups;
public EmailMessages $emailMessages;
+ public Uploads $uploads;
+ public AudienceSegments $audienceSegments;
+ public Workflows $workflows;
+ public TransactionalGroups $transactionalGroups;
public function __construct(string $api_key)
{
@@ -30,6 +36,10 @@ public function __construct(string $api_key)
'http_errors' => false
]);
+ $this->uploadHttpClient = new \GuzzleHttp\Client(config: [
+ 'http_errors' => false,
+ ]);
+
$this->apiKey = new ApiKey(client: $this);
$this->contacts = new Contacts(client: $this);
$this->events = new Events(client: $this);
@@ -40,7 +50,12 @@ public function __construct(string $api_key)
$this->themes = new Themes(client: $this);
$this->components = new Components(client: $this);
$this->campaigns = new Campaigns(client: $this);
+ $this->campaignGroups = new CampaignGroups(client: $this);
$this->emailMessages = new EmailMessages(client: $this);
+ $this->uploads = new Uploads(client: $this);
+ $this->audienceSegments = new AudienceSegments(client: $this);
+ $this->workflows = new Workflows(client: $this);
+ $this->transactionalGroups = new TransactionalGroups(client: $this);
}
/**
@@ -54,6 +69,27 @@ public function setHttpClient(\GuzzleHttp\Client $client): void
$this->httpClient = $client;
}
+ public function getHttpClient(): \GuzzleHttp\Client
+ {
+ return $this->httpClient;
+ }
+
+ /**
+ * Sets the HTTP client used for pre-signed upload URLs. Primarily for testing.
+ *
+ * @param \GuzzleHttp\Client $client
+ * @return void
+ */
+ public function setUploadHttpClient(\GuzzleHttp\Client $client): void
+ {
+ $this->uploadHttpClient = $client;
+ }
+
+ public function getUploadHttpClient(): \GuzzleHttp\Client
+ {
+ return $this->uploadHttpClient;
+ }
+
/**
* Performs an HTTP request to the Loops API
*
diff --git a/src/Omit.php b/src/Omit.php
new file mode 100644
index 0000000..5d3466d
--- /dev/null
+++ b/src/Omit.php
@@ -0,0 +1,8 @@
+client->query(method: 'GET', endpoint: 'v1/themes', options: [
- 'query' => $query
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
]);
}
diff --git a/src/Transactional.php b/src/Transactional.php
index b7220d5..800f08d 100644
--- a/src/Transactional.php
+++ b/src/Transactional.php
@@ -18,7 +18,7 @@ public function send(
string $email,
?bool $add_to_audience = false,
?array $data_variables = [],
- ?array $attachments = [], /** @var array */
+ ?array $attachments = [], /** @var array */
?array $headers = []
): mixed {
$payload = [
@@ -35,17 +35,56 @@ public function send(
]);
}
- public function list(?int $per_page = 20, ?string $cursor = null): mixed
+ public function list(?int $per_page = null, ?string $cursor = null): mixed
{
+ return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails', options: [
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
+ ]);
+ }
- $query = [
- 'per_page' => $per_page
- ];
- if ($cursor)
- $query['cursor'] = $cursor;
+ public function create(string $name, ?string $transactional_group_id = null): mixed
+ {
+ return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails', options: [
+ 'json' => Util::omitNull([
+ 'name' => $name,
+ 'transactionalGroupId' => $transactional_group_id,
+ ])
+ ]);
+ }
- return $this->client->query(method: 'GET', endpoint: 'v1/transactional', options: [
- 'query' => $query
+ public function get(string $transactional_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails/' . $transactional_id);
+ }
+
+ public function update(
+ string $transactional_id,
+ ?string $name = null,
+ ?string $transactional_group_id = null
+ ): mixed {
+ $payload = Util::omitNull([
+ 'name' => $name,
+ 'transactionalGroupId' => $transactional_group_id,
+ ]);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one field must be provided.');
+ }
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id, options: [
+ 'json' => $payload
]);
}
-}
\ No newline at end of file
+
+ public function ensureDraft(string $transactional_id): mixed
+ {
+ return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/draft');
+ }
+
+ public function publish(string $transactional_id): mixed
+ {
+ return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/publish');
+ }
+}
diff --git a/src/TransactionalGroups.php b/src/TransactionalGroups.php
new file mode 100644
index 0000000..ba8e32e
--- /dev/null
+++ b/src/TransactionalGroups.php
@@ -0,0 +1,55 @@
+client = $client;
+ }
+
+ public function list(?int $per_page = null, ?string $cursor = null): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups', options: [
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
+ ]);
+ }
+
+ public function create(string $name, ?string $description = null): mixed
+ {
+ return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups', options: [
+ 'json' => Util::omitNull([
+ 'name' => $name,
+ 'description' => $description,
+ ])
+ ]);
+ }
+
+ public function get(string $transactional_group_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups/' . $transactional_group_id);
+ }
+
+ public function update(string $transactional_group_id, ?string $name = null, ?string $description = null): mixed
+ {
+ $payload = Util::omitNull([
+ 'name' => $name,
+ 'description' => $description,
+ ]);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one field must be provided.');
+ }
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups/' . $transactional_group_id, options: [
+ 'json' => $payload
+ ]);
+ }
+}
diff --git a/src/Uploads.php b/src/Uploads.php
new file mode 100644
index 0000000..45c4551
--- /dev/null
+++ b/src/Uploads.php
@@ -0,0 +1,97 @@
+ 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ ];
+
+ private const MAX_BYTES = 4000000;
+
+ private $client;
+
+ public function __construct(LoopsClient $client)
+ {
+ $this->client = $client;
+ }
+
+ public function upload(string $path): mixed
+ {
+ if (!is_readable($path)) {
+ throw new \InvalidArgumentException(message: 'File not found or not readable: ' . $path);
+ }
+
+ $contents = file_get_contents(filename: $path);
+ $content_length = strlen(string: $contents);
+
+ if ($content_length === 0) {
+ throw new \InvalidArgumentException(message: 'File is empty: ' . $path);
+ }
+
+ if ($content_length > self::MAX_BYTES) {
+ throw new \InvalidArgumentException(message: 'File exceeds the maximum allowed size of 4,000,000 bytes.');
+ }
+
+ $content_type = $this->resolveContentType(path: $path);
+ if ($content_type === null) {
+ throw new \InvalidArgumentException(message: 'Unsupported image type. Supported types: JPEG, PNG, GIF, and WebP.');
+ }
+
+ $created = $this->client->query(method: 'POST', endpoint: 'v1/uploads', options: [
+ 'json' => [
+ 'contentType' => $content_type,
+ 'contentLength' => $content_length,
+ ]
+ ]);
+
+ $response = $this->client->getUploadHttpClient()->put($created['presignedUrl'], [
+ 'headers' => [
+ 'Content-Type' => $content_type,
+ 'Content-Length' => (string) $content_length,
+ ],
+ 'body' => $contents,
+ 'http_errors' => false,
+ ]);
+
+ if ($response->getStatusCode() >= 400) {
+ throw new \RuntimeException(
+ message: 'Failed to upload file to pre-signed URL. HTTP status: ' . $response->getStatusCode(),
+ code: $response->getStatusCode()
+ );
+ }
+
+ return $this->client->query(
+ method: 'POST',
+ endpoint: 'v1/uploads/' . $created['emailAssetId'] . '/complete'
+ );
+ }
+
+ private function resolveContentType(string $path): ?string
+ {
+ $finfo = finfo_open(flags: FILEINFO_MIME_TYPE);
+ $mime = finfo_file(finfo: $finfo, filename: $path);
+
+ if (in_array(needle: $mime, haystack: self::ALLOWED_MIME_TYPES, strict: true)) {
+ return $mime;
+ }
+
+ $extension = strtolower(string: pathinfo(path: $path, flags: PATHINFO_EXTENSION));
+
+ return self::EXTENSION_MIME_TYPES[$extension] ?? null;
+ }
+}
diff --git a/src/Util.php b/src/Util.php
new file mode 100644
index 0000000..d730814
--- /dev/null
+++ b/src/Util.php
@@ -0,0 +1,30 @@
+ $params
+ * @return array
+ */
+ public static function omitNull(array $params): array
+ {
+ return array_filter($params, fn ($value) => $value !== null);
+ }
+
+ /**
+ * Returns a copy of the array with unset sentinel values removed.
+ * Use for patch parameters where null means "clear" and UNSET means "not provided".
+ *
+ * @param array $params
+ * @return array
+ */
+ public static function omitUnset(array $params): array
+ {
+ return array_filter($params, fn ($value) => $value !== Core::UNSET);
+ }
+}
diff --git a/src/Workflows.php b/src/Workflows.php
new file mode 100644
index 0000000..6a9abf6
--- /dev/null
+++ b/src/Workflows.php
@@ -0,0 +1,35 @@
+client = $client;
+ }
+
+ public function list(?int $per_page = null, ?string $cursor = null): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/workflows', options: [
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
+ ]);
+ }
+
+ public function get(string $workflow_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $workflow_id);
+ }
+
+ public function getNode(string $workflow_id, string $node_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id);
+ }
+}
diff --git a/tests/AudienceSegmentsTest.php b/tests/AudienceSegmentsTest.php
new file mode 100644
index 0000000..c417055
--- /dev/null
+++ b/tests/AudienceSegmentsTest.php
@@ -0,0 +1,66 @@
+mockHttpClient = $this->createMock(\GuzzleHttp\Client::class);
+ $this->client = new LoopsClient('test_api_key');
+ $this->client->setHttpClient($this->mockHttpClient);
+ }
+
+ public function testListAudienceSegments(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with(
+ 'v1/audience-segments',
+ $this->callback(function ($options) {
+ return $options['query']['perPage'] === 20
+ && $options['query']['cursor'] === 'cursor123';
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'pagination' => ['nextCursor' => null],
+ 'data' => []
+ ])
+ ));
+
+ $result = $this->client->audienceSegments->list(per_page: 20, cursor: 'cursor123');
+
+ $this->assertEquals([], $result['data']);
+ }
+
+ public function testGetAudienceSegment(): void
+ {
+ $segmentId = 'seg_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/audience-segments/' . $segmentId)
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $segmentId,
+ 'name' => 'Active subscribers'
+ ])
+ ));
+
+ $result = $this->client->audienceSegments->get(audience_segment_id: $segmentId);
+
+ $this->assertEquals($segmentId, $result['id']);
+ }
+}
diff --git a/tests/CampaignGroupsTest.php b/tests/CampaignGroupsTest.php
new file mode 100644
index 0000000..8cc6c19
--- /dev/null
+++ b/tests/CampaignGroupsTest.php
@@ -0,0 +1,68 @@
+mockHttpClient = $this->createMock(\GuzzleHttp\Client::class);
+ $this->client = new LoopsClient('test_api_key');
+ $this->client->setHttpClient($this->mockHttpClient);
+ }
+
+ public function testCreateCampaignGroup(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/campaign-groups',
+ $this->callback(function ($options) {
+ return $options['json']['name'] === 'Newsletters'
+ && $options['json']['description'] === 'Monthly';
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode(['id' => 'grp_123', 'name' => 'Newsletters'])
+ ));
+
+ $result = $this->client->campaignGroups->create(name: 'Newsletters', description: 'Monthly');
+
+ $this->assertEquals('grp_123', $result['id']);
+ }
+
+ public function testUpdateCampaignGroup(): void
+ {
+ $groupId = 'grp_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/campaign-groups/' . $groupId,
+ $this->callback(function ($options) {
+ return $options['json']['name'] === 'Updated name';
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode(['id' => $groupId, 'name' => 'Updated name'])
+ ));
+
+ $result = $this->client->campaignGroups->update(
+ campaign_group_id: $groupId,
+ name: 'Updated name'
+ );
+
+ $this->assertEquals('Updated name', $result['name']);
+ }
+}
diff --git a/tests/CampaignsTest.php b/tests/CampaignsTest.php
index 39ee98d..b96fdb0 100644
--- a/tests/CampaignsTest.php
+++ b/tests/CampaignsTest.php
@@ -127,4 +127,80 @@ public function testUpdateCampaign(): void
$this->assertEquals('Updated name', $result['name']);
}
+
+ public function testUpdateCampaignClearsMailingList(): void
+ {
+ $campaignId = 'camp_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/campaigns/' . $campaignId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'name' => 'Spring announcement',
+ 'mailingListId' => null,
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'success' => true,
+ 'campaignId' => $campaignId,
+ 'name' => 'Spring announcement',
+ 'status' => 'Draft',
+ 'createdAt' => '2025-01-01T00:00:00.000Z',
+ 'updatedAt' => '2025-01-02T00:00:00.000Z',
+ 'emailMessageId' => 'msg_123'
+ ])
+ ));
+
+ $result = $this->client->campaigns->update(
+ campaign_id: $campaignId,
+ name: 'Spring announcement',
+ mailing_list_id: null
+ );
+
+ $this->assertTrue($result['success']);
+ }
+
+ public function testUpdateCampaignClearsAudienceFilter(): void
+ {
+ $campaignId = 'camp_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/campaigns/' . $campaignId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'name' => 'Spring announcement',
+ 'audienceFilter' => null,
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'success' => true,
+ 'campaignId' => $campaignId,
+ 'name' => 'Spring announcement',
+ 'status' => 'Draft',
+ 'createdAt' => '2025-01-01T00:00:00.000Z',
+ 'updatedAt' => '2025-01-02T00:00:00.000Z',
+ 'emailMessageId' => 'msg_123'
+ ])
+ ));
+
+ $result = $this->client->campaigns->update(
+ campaign_id: $campaignId,
+ name: 'Spring announcement',
+ audience_filter: null
+ );
+
+ $this->assertTrue($result['success']);
+ }
}
diff --git a/tests/EmailMessagesTest.php b/tests/EmailMessagesTest.php
index 028db8c..2180711 100644
--- a/tests/EmailMessagesTest.php
+++ b/tests/EmailMessagesTest.php
@@ -51,19 +51,19 @@ public function testFindEmailMessage(): void
public function testUpdateEmailMessage(): void
{
$emailMessageId = 'msg_123';
- $fields = [
- 'expectedRevisionId' => 'rev_123',
- 'subject' => 'Updated subject',
- 'lmx' => 'Hello'
- ];
+ $lmx = 'Hello';
$this->mockHttpClient
->expects($this->once())
->method('post')
->with(
'v1/email-messages/' . $emailMessageId,
- $this->callback(function ($options) use ($fields) {
- return $options['json'] === $fields;
+ $this->callback(function ($options) use ($lmx) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'subject' => 'Updated subject',
+ 'lmx' => $lmx,
+ ];
})
)
->willReturn(new Response(
@@ -77,7 +77,7 @@ public function testUpdateEmailMessage(): void
'fromName' => 'Loops',
'fromEmail' => 'hello',
'replyToEmail' => '',
- 'lmx' => $fields['lmx'],
+ 'lmx' => $lmx,
'contentRevisionId' => 'rev_456',
'updatedAt' => '2025-01-02T00:00:00.000Z'
])
@@ -85,10 +85,43 @@ public function testUpdateEmailMessage(): void
$result = $this->client->emailMessages->update(
email_message_id: $emailMessageId,
- fields: $fields
+ expected_revision_id: 'rev_123',
+ subject: 'Updated subject',
+ lmx: $lmx
);
$this->assertEquals('Updated subject', $result['subject']);
$this->assertEquals('rev_456', $result['contentRevisionId']);
}
+
+ public function testPreviewEmailMessage(): void
+ {
+ $emailMessageId = 'msg_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/email-messages/' . $emailMessageId . '/preview',
+ $this->callback(function ($options) {
+ return $options['json']['emails'] === ['test@example.com']
+ && $options['json']['contactProperties'] === ['firstName' => 'Ada'];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'success' => true,
+ 'emailMessageId' => $emailMessageId
+ ])
+ ));
+
+ $result = $this->client->emailMessages->preview(
+ email_message_id: $emailMessageId,
+ emails: ['test@example.com'],
+ contact_properties: ['firstName' => 'Ada']
+ );
+
+ $this->assertTrue($result['success']);
+ }
}
diff --git a/tests/TransactionalGroupsTest.php b/tests/TransactionalGroupsTest.php
new file mode 100644
index 0000000..a10d403
--- /dev/null
+++ b/tests/TransactionalGroupsTest.php
@@ -0,0 +1,57 @@
+mockHttpClient = $this->createMock(\GuzzleHttp\Client::class);
+ $this->client = new LoopsClient('test_api_key');
+ $this->client->setHttpClient($this->mockHttpClient);
+ }
+
+ public function testListTransactionalGroups(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/transactional-groups')
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'pagination' => ['nextCursor' => null],
+ 'data' => []
+ ])
+ ));
+
+ $result = $this->client->transactionalGroups->list();
+
+ $this->assertEquals([], $result['data']);
+ }
+
+ public function testGetTransactionalGroup(): void
+ {
+ $groupId = 'tgrp_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/transactional-groups/' . $groupId)
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode(['id' => $groupId, 'name' => 'Onboarding'])
+ ));
+
+ $result = $this->client->transactionalGroups->get(transactional_group_id: $groupId);
+
+ $this->assertEquals($groupId, $result['id']);
+ }
+}
diff --git a/tests/TransactionalTest.php b/tests/TransactionalTest.php
index 6121b52..3bd488d 100644
--- a/tests/TransactionalTest.php
+++ b/tests/TransactionalTest.php
@@ -105,7 +105,7 @@ public function testSendTransactional(): void
$this->assertTrue($result['success']);
}
- public function testGetTransactionals(): void
+ public function testListTransactionals(): void
{
$per_page = 20;
$cursor = 'clyo0q4wo01p59fsecyxqsh38';
@@ -115,11 +115,11 @@ public function testGetTransactionals(): void
->expects($this->once())
->method('get')
->with(
- 'v1/transactional',
+ 'v1/transactional-emails',
$this->callback(function ($options) use ($per_page, $cursor) {
// Verify the query parameters are passed correctly
return isset($options['query'])
- && $options['query']['per_page'] === $per_page
+ && $options['query']['perPage'] === $per_page
&& $options['query']['cursor'] === $cursor;
})
)
@@ -132,13 +132,16 @@ public function testGetTransactionals(): void
'perPage' => 20,
'totalPages' => 2,
'nextCursor' => 'clyo0q4wo01p59fsecyxqsh38',
- 'nextPage' => 'https://app.loops.so/api/v1/transactional?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20'
+ 'nextPage' => 'https://app.loops.so/api/v1/transactional-emails?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20'
],
'data' => [
[
'id' => 'clfn0k1yg001imo0fdeqg30i8',
'name' => 'Welcome email',
- 'lastUpdated' => '2023-11-06T17:48:07.249Z',
+ 'draftEmailMessageId' => null,
+ 'publishedEmailMessageId' => 'msg_abc123',
+ 'createdAt' => '2023-11-06T17:48:07.249Z',
+ 'updatedAt' => '2023-11-06T17:48:07.249Z',
'dataVariables' => []
]
]
@@ -169,7 +172,207 @@ public function testGetTransactionals(): void
$this->assertNotEmpty($result['data']);
$this->assertArrayHasKey('id', $result['data'][0]);
$this->assertArrayHasKey('name', $result['data'][0]);
- $this->assertArrayHasKey('lastUpdated', $result['data'][0]);
+ $this->assertArrayHasKey('draftEmailMessageId', $result['data'][0]);
+ $this->assertArrayHasKey('publishedEmailMessageId', $result['data'][0]);
+ $this->assertArrayHasKey('createdAt', $result['data'][0]);
+ $this->assertArrayHasKey('updatedAt', $result['data'][0]);
$this->assertArrayHasKey('dataVariables', $result['data'][0]);
}
+
+ public function testCreateTransactional(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/transactional-emails',
+ $this->callback(function ($options) {
+ return $options['json']['name'] === 'Welcome email';
+ })
+ )
+ ->willReturn(new Response(
+ status: 201,
+ body: json_encode([
+ 'id' => 'txn_123',
+ 'name' => 'Welcome email',
+ 'draftEmailMessageId' => 'msg_123',
+ 'draftEmailMessageContentRevisionId' => 'rev_123',
+ 'publishedEmailMessageId' => null,
+ 'createdAt' => '2025-01-01T00:00:00.000Z',
+ 'updatedAt' => '2025-01-01T00:00:00.000Z',
+ 'dataVariables' => []
+ ])
+ ));
+
+ $result = $this->client->transactional->create(name: 'Welcome email');
+
+ $this->assertEquals('txn_123', $result['id']);
+ $this->assertEquals('msg_123', $result['draftEmailMessageId']);
+ }
+
+ public function testCreateTransactionalWithGroup(): void
+ {
+ $groupId = 'grp_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/transactional-emails',
+ $this->callback(function ($options) use ($groupId) {
+ return $options['json']['name'] === 'Welcome email'
+ && $options['json']['transactionalGroupId'] === $groupId;
+ })
+ )
+ ->willReturn(new Response(status: 201, body: json_encode(['id' => 'txn_123'])));
+
+ $this->client->transactional->create(
+ name: 'Welcome email',
+ transactional_group_id: $groupId
+ );
+ }
+
+ public function testGetTransactional(): void
+ {
+ $transactionalId = 'txn_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/transactional-emails/' . $transactionalId)
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $transactionalId,
+ 'name' => 'Welcome email',
+ 'draftEmailMessageId' => 'msg_123',
+ 'publishedEmailMessageId' => 'msg_456',
+ 'createdAt' => '2025-01-01T00:00:00.000Z',
+ 'updatedAt' => '2025-01-01T00:00:00.000Z',
+ 'dataVariables' => ['firstName']
+ ])
+ ));
+
+ $result = $this->client->transactional->get(transactional_id: $transactionalId);
+
+ $this->assertEquals($transactionalId, $result['id']);
+ }
+
+ public function testUpdateTransactional(): void
+ {
+ $transactionalId = 'txn_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/transactional-emails/' . $transactionalId,
+ $this->callback(function ($options) {
+ return $options['json']['name'] === 'Updated welcome email';
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $transactionalId,
+ 'name' => 'Updated welcome email',
+ 'draftEmailMessageId' => 'msg_123',
+ 'publishedEmailMessageId' => null,
+ 'createdAt' => '2025-01-01T00:00:00.000Z',
+ 'updatedAt' => '2025-01-02T00:00:00.000Z',
+ 'dataVariables' => []
+ ])
+ ));
+
+ $result = $this->client->transactional->update(
+ transactional_id: $transactionalId,
+ name: 'Updated welcome email'
+ );
+
+ $this->assertEquals('Updated welcome email', $result['name']);
+ }
+
+ public function testUpdateTransactionalGroup(): void
+ {
+ $transactionalId = 'txn_123';
+ $groupId = 'grp_456';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/transactional-emails/' . $transactionalId,
+ $this->callback(function ($options) use ($groupId) {
+ return $options['json']['transactionalGroupId'] === $groupId
+ && !isset($options['json']['name']);
+ })
+ )
+ ->willReturn(new Response(status: 200, body: json_encode(['id' => $transactionalId])));
+
+ $this->client->transactional->update(
+ transactional_id: $transactionalId,
+ transactional_group_id: $groupId
+ );
+ }
+
+ public function testUpdateTransactionalWithoutFieldsThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('At least one field must be provided.');
+
+ $this->client->transactional->update(transactional_id: 'txn_123');
+ }
+
+ public function testEnsureDraftTransactional(): void
+ {
+ $transactionalId = 'txn_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with('v1/transactional-emails/' . $transactionalId . '/draft')
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $transactionalId,
+ 'name' => 'Welcome email',
+ 'draftEmailMessageId' => 'msg_123',
+ 'draftEmailMessageContentRevisionId' => 'rev_123',
+ 'publishedEmailMessageId' => 'msg_456',
+ 'createdAt' => '2025-01-01T00:00:00.000Z',
+ 'updatedAt' => '2025-01-02T00:00:00.000Z',
+ 'dataVariables' => []
+ ])
+ ));
+
+ $result = $this->client->transactional->ensureDraft(transactional_id: $transactionalId);
+
+ $this->assertEquals('msg_123', $result['draftEmailMessageId']);
+ }
+
+ public function testPublishTransactional(): void
+ {
+ $transactionalId = 'txn_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with('v1/transactional-emails/' . $transactionalId . '/publish')
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $transactionalId,
+ 'name' => 'Welcome email',
+ 'draftEmailMessageId' => null,
+ 'publishedEmailMessageId' => 'msg_123',
+ 'createdAt' => '2025-01-01T00:00:00.000Z',
+ 'updatedAt' => '2025-01-02T00:00:00.000Z',
+ 'dataVariables' => ['firstName']
+ ])
+ ));
+
+ $result = $this->client->transactional->publish(transactional_id: $transactionalId);
+
+ $this->assertEquals('msg_123', $result['publishedEmailMessageId']);
+ }
}
\ No newline at end of file
diff --git a/tests/UploadsTest.php b/tests/UploadsTest.php
new file mode 100644
index 0000000..e2bd52a
--- /dev/null
+++ b/tests/UploadsTest.php
@@ -0,0 +1,114 @@
+mockHttpClient = $this->createMock(\GuzzleHttp\Client::class);
+ $this->mockUploadHttpClient = $this->createMock(\GuzzleHttp\Client::class);
+ $this->client = new LoopsClient('test_api_key');
+ $this->client->setHttpClient($this->mockHttpClient);
+ $this->client->setUploadHttpClient($this->mockUploadHttpClient);
+
+ $this->imagePath = sys_get_temp_dir() . '/loops_upload_test.png';
+ file_put_contents(
+ $this->imagePath,
+ base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==')
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ if (file_exists($this->imagePath)) {
+ unlink($this->imagePath);
+ }
+ }
+
+ public function testUpload(): void
+ {
+ $presignedUrl = 'https://example.com/upload';
+ $assetId = 'asset_123';
+ $fileContents = file_get_contents($this->imagePath);
+ $contentLength = strlen($fileContents);
+
+ $this->mockHttpClient
+ ->expects($this->exactly(2))
+ ->method('post')
+ ->willReturnCallback(function ($endpoint, $options = []) use ($presignedUrl, $assetId, $contentLength) {
+ if ($endpoint === 'v1/uploads') {
+ $this->assertEquals('image/png', $options['json']['contentType']);
+ $this->assertEquals($contentLength, $options['json']['contentLength']);
+
+ return new Response(
+ status: 200,
+ body: json_encode([
+ 'emailAssetId' => $assetId,
+ 'presignedUrl' => $presignedUrl,
+ ])
+ );
+ }
+
+ if ($endpoint === 'v1/uploads/' . $assetId . '/complete') {
+ return new Response(
+ status: 200,
+ body: json_encode([
+ 'emailAssetId' => $assetId,
+ 'finalUrl' => 'https://cdn.example.com/image.png',
+ ])
+ );
+ }
+
+ $this->fail('Unexpected POST endpoint: ' . $endpoint);
+ });
+
+ $this->mockUploadHttpClient
+ ->expects($this->once())
+ ->method('put')
+ ->with(
+ $presignedUrl,
+ $this->callback(function ($options) use ($fileContents, $contentLength) {
+ return !isset($options['headers']['Authorization'])
+ && $options['headers']['Content-Type'] === 'image/png'
+ && $options['headers']['Content-Length'] === (string) $contentLength
+ && $options['body'] === $fileContents;
+ })
+ )
+ ->willReturn(new Response(status: 200));
+
+ $result = $this->client->uploads->upload(path: $this->imagePath);
+
+ $this->assertEquals($assetId, $result['emailAssetId']);
+ $this->assertEquals('https://cdn.example.com/image.png', $result['finalUrl']);
+ }
+
+ public function testUploadRejectsMissingFile(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ $this->client->uploads->upload(path: '/path/does/not/exist.png');
+ }
+
+ public function testUploadRejectsUnsupportedType(): void
+ {
+ $path = sys_get_temp_dir() . '/loops_upload_test.txt';
+ file_put_contents($path, 'not an image');
+
+ try {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->client->uploads->upload(path: $path);
+ } finally {
+ unlink($path);
+ }
+ }
+}
diff --git a/tests/UtilTest.php b/tests/UtilTest.php
new file mode 100644
index 0000000..6e36b76
--- /dev/null
+++ b/tests/UtilTest.php
@@ -0,0 +1,61 @@
+ 'Test',
+ 'campaignGroupId' => null,
+ 'mailingListId' => 'list_123',
+ 'audienceSegmentId' => null,
+ ]);
+
+ $this->assertEquals([
+ 'name' => 'Test',
+ 'mailingListId' => 'list_123',
+ ], $result);
+ }
+
+ public function testOmitNullKeepsFalsyNonNullValues(): void
+ {
+ $result = Util::omitNull([
+ 'addToAudience' => false,
+ 'dataVariables' => [],
+ 'count' => 0,
+ ]);
+
+ $this->assertEquals([
+ 'addToAudience' => false,
+ 'dataVariables' => [],
+ 'count' => 0,
+ ], $result);
+ }
+
+ public function testOmitUnsetRemovesUnsetValues(): void
+ {
+ $result = Util::omitUnset([
+ 'name' => 'Updated',
+ 'mailingListId' => Core::UNSET,
+ 'audienceFilter' => Core::UNSET,
+ ]);
+
+ $this->assertEquals(['name' => 'Updated'], $result);
+ }
+
+ public function testOmitUnsetKeepsExplicitNull(): void
+ {
+ $result = Util::omitUnset([
+ 'mailingListId' => null,
+ 'audienceFilter' => Core::UNSET,
+ ]);
+
+ $this->assertEquals(['mailingListId' => null], $result);
+ }
+}
diff --git a/tests/WorkflowsTest.php b/tests/WorkflowsTest.php
new file mode 100644
index 0000000..2269cd9
--- /dev/null
+++ b/tests/WorkflowsTest.php
@@ -0,0 +1,61 @@
+mockHttpClient = $this->createMock(\GuzzleHttp\Client::class);
+ $this->client = new LoopsClient('test_api_key');
+ $this->client->setHttpClient($this->mockHttpClient);
+ }
+
+ public function testListWorkflows(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/workflows')
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'pagination' => ['nextCursor' => null],
+ 'data' => []
+ ])
+ ));
+
+ $result = $this->client->workflows->list();
+
+ $this->assertEquals([], $result['data']);
+ }
+
+ public function testGetWorkflowNode(): void
+ {
+ $workflowId = 'wf_123';
+ $nodeId = 'node_456';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/workflows/' . $workflowId . '/nodes/' . $nodeId)
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $nodeId,
+ 'type' => 'email'
+ ])
+ ));
+
+ $result = $this->client->workflows->getNode(workflow_id: $workflowId, node_id: $nodeId);
+
+ $this->assertEquals($nodeId, $result['id']);
+ }
+}