diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22ad4a0..e006f53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+## v3.2.0 - Aug 3, 2026
+
+OpenAPI 1.21.1:
+
+- Added workflow write APIs: `workflows->create()`, `workflows->update()`, `workflows->changeMailingList()`, `workflows->createNode()`, `workflows->updateNode()`, `workflows->deleteNode()`, `workflows->addBranch()`, and `workflows->deleteNodeRecursive()`.
+- Added `eventPatterns->list()`, `eventPatterns->get()`, and `eventPatterns->getByName()`.
+- Added `audienceSegments->create()`.
+- Added `themes->create()` / `themes->update()` and `components->create()` / `components->update()`.
+- Added `emailMessages->guardian()` and extra update fields (`cc_email`, `bcc_email`, `language_code`, `email_format`).
+- `campaigns->update()` now rejects empty payloads.
+
## v3.1.1 - Jun 30, 2026
`$name` is no longer required when updating campaigns.
diff --git a/README.md b/README.md
index 5f80839..e139ba0 100644
--- a/README.md
+++ b/README.md
@@ -118,8 +118,12 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [dedicatedSendingIps->list()](#dedicatedsendingips-list)
- [themes->list()](#themes-list)
- [themes->get()](#themes-get)
+- [themes->create()](#themes-create)
+- [themes->update()](#themes-update)
- [components->list()](#components-list)
- [components->get()](#components-get)
+- [components->create()](#components-create)
+- [components->update()](#components-update)
- [campaigns->list()](#campaigns-list)
- [campaigns->create()](#campaigns-create)
- [campaigns->get()](#campaigns-get)
@@ -129,13 +133,26 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [campaignGroups->get()](#campaigngroups-get)
- [campaignGroups->update()](#campaigngroups-update)
- [audienceSegments->list()](#audiencesegments-list)
+- [audienceSegments->create()](#audiencesegments-create)
- [audienceSegments->get()](#audiencesegments-get)
- [workflows->list()](#workflows-list)
+- [workflows->create()](#workflows-create)
- [workflows->get()](#workflows-get)
+- [workflows->update()](#workflows-update)
+- [workflows->changeMailingList()](#workflows-changemailinglist)
- [workflows->getNode()](#workflows-getnode)
+- [workflows->createNode()](#workflows-createnode)
+- [workflows->updateNode()](#workflows-updatenode)
+- [workflows->deleteNode()](#workflows-deletenode)
+- [workflows->addBranch()](#workflows-addbranch)
+- [workflows->deleteNodeRecursive()](#workflows-deletenoderecursive)
+- [eventPatterns->list()](#eventpatterns-list)
+- [eventPatterns->get()](#eventpatterns-get)
+- [eventPatterns->getByName()](#eventpatterns-getbyname)
- [emailMessages->get()](#emailmessages-get)
- [emailMessages->update()](#emailmessages-update)
- [emailMessages->preview()](#emailmessages-preview)
+- [emailMessages->guardian()](#emailmessages-guardian)
- [transactionalGroups->list()](#transactionalgroups-list)
- [transactionalGroups->create()](#transactionalgroups-create)
- [transactionalGroups->get()](#transactionalgroups-get)
@@ -1159,6 +1176,57 @@ $result = $loops->themes->get(theme_id: 'clo5p8q0r0132ntx6flkunw89');
---
+### themes->create()
+
+Create an email theme.
+
+[API Reference](https://loops.so/docs/api-reference/create-theme)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------- | ------ | -------- | --------------------------------------------------------------------- |
+| `$name` | string | Yes | The theme name. |
+| `$styles` | array | No | Style attributes matching LMX `` attribute names. |
+
+#### Example
+
+```php
+$result = $loops->themes->create(
+ name: 'Dark mode',
+ styles: ['backgroundColor' => '#111827', 'bodyColor' => '#1f2937']
+);
+```
+
+---
+
+### themes->update()
+
+Update a theme's name and/or styles. Style changes cascade to emails using the theme.
+
+At least one of `$name` or `$styles` must be provided.
+
+[API Reference](https://loops.so/docs/api-reference/update-theme)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ----------- | ------ | -------- | -------------------------- |
+| `$theme_id` | string | Yes | The ID of the theme. |
+| `$name` | string | No | The updated theme name. |
+| `$styles` | array | No | Updated style attributes. |
+
+#### Example
+
+```php
+$result = $loops->themes->update(
+ theme_id: 'clo5p8q0r0132ntx6flkunw89',
+ name: 'Updated theme'
+);
+```
+
+---
+
### components->list()
List email components.
@@ -1200,6 +1268,57 @@ $result = $loops->components->get(component_id: 'clp6q9r1s0154ouy7gmlovx90');
---
+### components->create()
+
+Create an email component from an LMX body.
+
+[API Reference](https://loops.so/docs/api-reference/create-component)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------- | ------ | -------- | --------------------------------- |
+| `$name` | string | Yes | The component name. |
+| `$lmx` | string | Yes | The component body as LMX. |
+
+#### Example
+
+```php
+$result = $loops->components->create(
+ name: 'Header',
+ lmx: 'Welcome to Acme'
+);
+```
+
+---
+
+### components->update()
+
+Update a component's name and/or LMX body. Body changes cascade to emails using the component.
+
+At least one of `$name` or `$lmx` must be provided.
+
+[API Reference](https://loops.so/docs/api-reference/update-component)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | ------ | -------- | ---------------------------- |
+| `$component_id` | string | Yes | The ID of the component. |
+| `$name` | string | No | The updated component name. |
+| `$lmx` | string | No | The updated LMX body. |
+
+#### Example
+
+```php
+$result = $loops->components->update(
+ component_id: 'clp6q9r1s0154ouy7gmlovx90',
+ lmx: 'Updated header'
+);
+```
+
+---
+
### campaigns->list()
List campaigns.
@@ -1234,8 +1353,8 @@ Create a new draft campaign.
| `$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. |
+| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this without also providing `audience_filter` clears any existing `audience_filter`. If both are provided, the filter is applied on top of the segment's filter. |
+| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. Setting this without also providing `audience_segment_id` clears any existing `audience_segment_id`. |
| `$scheduling` | array | No | When the campaign should send. Use `['method' => 'now']` or `['method' => 'schedule', 'timestamp' => '...']`. |
#### Example
@@ -1291,7 +1410,7 @@ $result = $loops->campaigns->get(campaign_id: 'cln4o7p9q0110msw5ekjtmv78');
Update a draft campaign's name, group, audience, or scheduling.
-At least one field alongside `campaign_id` must be provided.
+At least one field must be provided.
[API Reference](https://loops.so/docs/api-reference/update-campaign)
@@ -1304,8 +1423,8 @@ At least one field alongside `campaign_id` must be provided.
| `$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. |
+| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this without also providing `audience_filter` clears any existing `audience_filter`. If both are provided, the filter is applied on top of the segment's filter. Pass `null` to clear. |
+| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. Setting this without also providing `audience_segment_id` clears any existing `audience_segment_id`. Pass `null` to clear. |
#### Example
@@ -1435,6 +1554,42 @@ $result = $loops->audienceSegments->list();
---
+### audienceSegments->create()
+
+Create an audience segment.
+
+[API Reference](https://loops.so/docs/api-reference/create-audience-segment)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------------- | ------ | -------- | --------------------------------------------------------------------- |
+| `$name` | string | Yes | The segment name. Must be unique within the team. |
+| `$filter` | array | Yes | A tree of audience conditions with `match` and `conditions`. |
+| `$description` | string | No | An optional description of the audience segment. |
+
+#### Example
+
+```php
+$result = $loops->audienceSegments->create(
+ name: 'Power users',
+ filter: [
+ 'match' => 'all',
+ 'conditions' => [
+ [
+ 'type' => 'property',
+ 'key' => 'plan',
+ 'operator' => 'equals',
+ 'value' => 'pro',
+ ],
+ ],
+ ],
+ description: 'Contacts on the pro plan'
+);
+```
+
+---
+
### audienceSegments->get()
Get an audience segment by ID.
@@ -1476,6 +1631,31 @@ $result = $loops->workflows->list();
---
+### workflows->create()
+
+Create a draft workflow with a blank trigger and exit node.
+
+[API Reference](https://loops.so/docs/api-reference/create-workflow)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------ | ------ | -------- | -------------------------------------------------- |
+| `$name` | string | Yes | The workflow name. |
+| `$description` | string | No | The workflow description. |
+| `$mailing_list_id` | string | No | The mailing list the workflow sends to. |
+
+#### Example
+
+```php
+$result = $loops->workflows->create(
+ name: 'Welcome series',
+ description: 'Onboarding emails for new signups'
+);
+```
+
+---
+
### workflows->get()
Get a simplified workflow graph.
@@ -1496,6 +1676,66 @@ $result = $loops->workflows->get(workflow_id: 'cls9t2u4v0210rx20jpuary23');
---
+### workflows->update()
+
+Update a workflow's display properties.
+
+At least one of `$name` or `$description` must be provided. To change the mailing list, use `workflows->changeMailingList()`.
+
+[API Reference](https://loops.so/docs/api-reference/update-workflow)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------------- | ------ | -------- | --------------------------------------------------------------------- |
+| `$workflow_id` | string | Yes | The ID of the workflow. |
+| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
+| `$name` | string | No | The updated workflow name. |
+| `$description` | string | No | The updated workflow description. |
+
+#### Example
+
+```php
+$result = $loops->workflows->update(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
+ name: 'Updated welcome series'
+);
+```
+
+---
+
+### workflows->changeMailingList()
+
+Dry run or apply a workflow mailing list change.
+
+If queued contacts would be removed, the API returns `"status": "queuedContactsFound"`. Retry with `queued_contact_policy: "discard"` to apply the change.
+
+[API Reference](https://loops.so/docs/api-reference/change-workflow-mailing-list)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------------- | ------- | -------- | --------------------------------------------------------------------- |
+| `$workflow_id` | string | Yes | The ID of the workflow. |
+| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
+| `$mailing_list_id` | string | Yes | The mailing list to use. Pass `null` to clear. |
+| `$dry_run` | boolean | No | If `true`, validate without modifying the workflow. |
+| `$queued_contact_policy` | string | No | `fail` (default) or `discard`. |
+
+#### Example
+
+```php
+$result = $loops->workflows->changeMailingList(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
+ mailing_list_id: 'cm06f5v0e45nf0ml5754o9cix',
+ dry_run: true
+);
+```
+
+---
+
### workflows->getNode()
Get detailed data for a single workflow node.
@@ -1520,6 +1760,216 @@ $result = $loops->workflows->getNode(
---
+### workflows->createNode()
+
+Create a new default workflow node.
+
+Use `insert_mode: "between"` with `from_node_id` and `to_node_id`, or `insert_mode: "before"` with `before_node_id`.
+
+[API Reference](https://loops.so/docs/api-reference/create-workflow-node)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------------- | ------ | -------- | --------------------------------------------------------------------- |
+| `$workflow_id` | string | Yes | The ID of the workflow. |
+| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
+| `$insert_mode` | string | Yes | `between` or `before`. |
+| `$node_type_name` | string | Yes | One of `AudienceFilter`, `BranchNode`, `ExperimentBranchNode`, `TimerAction`, `SendEmailAction`, `VariantNode`. |
+| `$from_node_id` | string | No | Required when `insert_mode` is `between`. |
+| `$to_node_id` | string | No | Required when `insert_mode` is `between`. |
+| `$before_node_id` | string | No | Required when `insert_mode` is `before`. |
+
+#### Example
+
+```php
+$result = $loops->workflows->createNode(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
+ insert_mode: 'between',
+ node_type_name: 'TimerAction',
+ from_node_id: 'clt0u3v5w0232sy31kqvbzs34',
+ to_node_id: 'clt0u3v5w0232sy31kqvbzs35'
+);
+```
+
+---
+
+### workflows->updateNode()
+
+Update workflow-node-owned fields for a single node.
+
+[API Reference](https://loops.so/docs/api-reference/update-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. |
+| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
+| `$payload` | array | Yes | Node-type-specific fields to update. |
+
+#### Example
+
+```php
+$result = $loops->workflows->updateNode(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ node_id: 'clt0u3v5w0232sy31kqvbzs34',
+ expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
+ payload: ['amount' => 2, 'unit' => 'd']
+);
+```
+
+---
+
+### workflows->deleteNode()
+
+Delete a single workflow node.
+
+If contacts are queued at the node, the API returns `"status": "queuedContactsFound"`. Retry with `queued_contact_policy: "discard"` to delete.
+
+[API Reference](https://loops.so/docs/api-reference/delete-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. |
+| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
+| `$dry_run` | boolean | No | If `true`, validate without modifying the workflow. |
+| `$queued_contact_policy` | string | No | `fail` (default) or `discard`. |
+
+#### Example
+
+```php
+$result = $loops->workflows->deleteNode(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ node_id: 'clt0u3v5w0232sy31kqvbzs34',
+ expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
+ dry_run: true
+);
+```
+
+---
+
+### workflows->addBranch()
+
+Add a branch and child node under an existing `BranchNode` or `ExperimentBranchNode`.
+
+[API Reference](https://loops.so/docs/api-reference/add-workflow-branch)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------------- | ------ | -------- | --------------------------------------------------------------------- |
+| `$workflow_id` | string | Yes | The ID of the workflow. |
+| `$node_id` | string | Yes | The ID of the branch or experiment node. |
+| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
+
+#### Example
+
+```php
+$result = $loops->workflows->addBranch(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ node_id: 'clt0u3v5w0232sy31kqvbzs34',
+ expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8'
+);
+```
+
+---
+
+### workflows->deleteNodeRecursive()
+
+Delete a node and its downstream subtree.
+
+[API Reference](https://loops.so/docs/api-reference/delete-workflow-nodes)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------------- | ------- | -------- | --------------------------------------------------------------------- |
+| `$workflow_id` | string | Yes | The ID of the workflow. |
+| `$node_id` | string | Yes | The root node ID of the subtree to delete. |
+| `$expected_revision_id` | string\|null | Yes | The latest workflow revision token. Pass `null` for older workflows. |
+| `$dry_run` | boolean | No | If `true`, validate without modifying the workflow. |
+| `$queued_contact_policy` | string | No | `fail` (default) or `discard`. |
+
+#### Example
+
+```php
+$result = $loops->workflows->deleteNodeRecursive(
+ workflow_id: 'cls9t2u4v0210rx20jpuary23',
+ node_id: 'clt0u3v5w0232sy31kqvbzs34',
+ expected_revision_id: 'clrev1s10n2i3d4e5f6g7h8',
+ dry_run: true
+);
+```
+
+---
+
+### eventPatterns->list()
+
+List event patterns available to workflow event trigger nodes.
+
+[API Reference](https://loops.so/docs/api-reference/list-event-patterns)
+
+#### 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->eventPatterns->list();
+```
+
+---
+
+### eventPatterns->get()
+
+Get an event pattern by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-event-pattern)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------------- | ------ | -------- | ---------------------------- |
+| `$event_pattern_id` | string | Yes | The ID of the event pattern. |
+
+#### Example
+
+```php
+$result = $loops->eventPatterns->get(event_pattern_id: 'cle1v2e3n4t5p6a7t8t9e0r1');
+```
+
+---
+
+### eventPatterns->getByName()
+
+Get an event pattern by event name. Event names are case-sensitive.
+
+[API Reference](https://loops.so/docs/api-reference/get-event-pattern-by-name)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------- | ------ | -------- | -------------------- |
+| `$event_name` | string | Yes | The exact event name.|
+
+#### Example
+
+```php
+$result = $loops->eventPatterns->getByName(event_name: 'signup');
+```
+
+---
+
### emailMessages->get()
Get an email message, including its compiled LMX content.
@@ -1544,7 +1994,7 @@ $result = $loops->emailMessages->get(email_message_id: 'cly8k3m0n0044jpx2bghepq4
Update an email message.
-At least one field alongside `email_message_id` must be provided.
+At least one field must be provided.
[API Reference](https://loops.so/docs/api-reference/update-email-message)
@@ -1559,6 +2009,10 @@ At least one field alongside `email_message_id` must be provided.
| `$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. |
+| `$cc_email` | string | No | CC email address. Requires the team to have CC/BCC enabled. |
+| `$bcc_email` | string | No | BCC email address. Requires the team to have CC/BCC enabled. |
+| `$language_code` | string | No | Language code for the email. Requires translation to be enabled for the team. |
+| `$email_format` | string | No | `styled` or `plain`. |
| `$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. |
@@ -1617,6 +2071,26 @@ $result = $loops->emailMessages->preview(
---
+### emailMessages->guardian()
+
+Run Guardian content validation on an email message and return errors and warnings.
+
+[API Reference](https://loops.so/docs/api-reference/run-guardian-checks)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------- | ------ | -------- | ---------------------------- |
+| `$email_message_id` | string | Yes | The ID of the email message. |
+
+#### Example
+
+```php
+$result = $loops->emailMessages->guardian(email_message_id: 'cly8k3m0n0044jpx2bghepq45');
+```
+
+---
+
### transactionalGroups->list()
List transactional groups.
diff --git a/src/AudienceSegments.php b/src/AudienceSegments.php
index 98afa9d..f638d94 100644
--- a/src/AudienceSegments.php
+++ b/src/AudienceSegments.php
@@ -23,6 +23,17 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed
]);
}
+ public function create(string $name, array $filter, ?string $description = null): mixed
+ {
+ return $this->client->query(method: 'POST', endpoint: 'v1/audience-segments', options: [
+ 'json' => Util::omitNull([
+ 'name' => $name,
+ 'filter' => $filter,
+ 'description' => $description,
+ ])
+ ]);
+ }
+
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/Campaigns.php b/src/Campaigns.php
index 38d1f40..ff80d8e 100644
--- a/src/Campaigns.php
+++ b/src/Campaigns.php
@@ -73,6 +73,9 @@ public function update(
'audienceFilter' => $audience_filter,
]),
);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one field must be provided.');
+ }
return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $campaign_id, options: [
'json' => $payload
diff --git a/src/Components.php b/src/Components.php
index c1af4b6..e94c089 100644
--- a/src/Components.php
+++ b/src/Components.php
@@ -23,8 +23,33 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed
]);
}
+ public function create(string $name, string $lmx): mixed
+ {
+ return $this->client->query(method: 'POST', endpoint: 'v1/components', options: [
+ 'json' => [
+ 'name' => $name,
+ 'lmx' => $lmx,
+ ]
+ ]);
+ }
+
public function get(string $component_id): mixed
{
return $this->client->query(method: 'GET', endpoint: 'v1/components/' . $component_id);
}
+
+ public function update(string $component_id, ?string $name = null, ?string $lmx = null): mixed
+ {
+ $payload = Util::omitNull([
+ 'name' => $name,
+ 'lmx' => $lmx,
+ ]);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one of name or lmx must be provided.');
+ }
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/components/' . $component_id, options: [
+ 'json' => $payload
+ ]);
+ }
}
diff --git a/src/EmailMessages.php b/src/EmailMessages.php
index 8daeaa9..38d9227 100644
--- a/src/EmailMessages.php
+++ b/src/EmailMessages.php
@@ -26,6 +26,10 @@ public function update(
?string $from_name = null,
?string $from_email = null,
?string $reply_to_email = null,
+ ?string $cc_email = null,
+ ?string $bcc_email = null,
+ ?string $language_code = null,
+ ?string $email_format = null,
?string $lmx = null,
?array $contact_properties_fallbacks = null,
?array $event_properties_fallbacks = null,
@@ -38,6 +42,10 @@ public function update(
'fromName' => $from_name,
'fromEmail' => $from_email,
'replyToEmail' => $reply_to_email,
+ 'ccEmail' => $cc_email,
+ 'bccEmail' => $bcc_email,
+ 'languageCode' => $language_code,
+ 'emailFormat' => $email_format,
'lmx' => $lmx,
'contactPropertiesFallbacks' => $contact_properties_fallbacks,
'eventPropertiesFallbacks' => $event_properties_fallbacks,
@@ -72,4 +80,9 @@ public function preview(
'json' => $payload
]);
}
+
+ public function guardian(string $email_message_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/email-messages/' . $email_message_id . '/guardian');
+ }
}
diff --git a/src/EventPatterns.php b/src/EventPatterns.php
new file mode 100644
index 0000000..4b26bd4
--- /dev/null
+++ b/src/EventPatterns.php
@@ -0,0 +1,38 @@
+client = $client;
+ }
+
+ public function list(?int $per_page = null, ?string $cursor = null): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/event-patterns', options: [
+ 'query' => Util::omitNull([
+ 'perPage' => $per_page,
+ 'cursor' => $cursor,
+ ])
+ ]);
+ }
+
+ public function get(string $event_pattern_id): mixed
+ {
+ return $this->client->query(method: 'GET', endpoint: 'v1/event-patterns/' . $event_pattern_id);
+ }
+
+ public function getByName(string $event_name): mixed
+ {
+ return $this->client->query(
+ method: 'GET',
+ endpoint: 'v1/event-patterns/by-name/' . rawurlencode($event_name)
+ );
+ }
+}
diff --git a/src/LoopsClient.php b/src/LoopsClient.php
index b86fd18..c997ecd 100644
--- a/src/LoopsClient.php
+++ b/src/LoopsClient.php
@@ -23,6 +23,7 @@ class LoopsClient
public Uploads $uploads;
public AudienceSegments $audienceSegments;
public Workflows $workflows;
+ public EventPatterns $eventPatterns;
public TransactionalGroups $transactionalGroups;
public function __construct(string $api_key)
@@ -55,6 +56,7 @@ public function __construct(string $api_key)
$this->uploads = new Uploads(client: $this);
$this->audienceSegments = new AudienceSegments(client: $this);
$this->workflows = new Workflows(client: $this);
+ $this->eventPatterns = new EventPatterns(client: $this);
$this->transactionalGroups = new TransactionalGroups(client: $this);
}
diff --git a/src/Themes.php b/src/Themes.php
index ad0ba2b..222a36a 100644
--- a/src/Themes.php
+++ b/src/Themes.php
@@ -23,8 +23,32 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed
]);
}
+ public function create(string $name, ?array $styles = null): mixed
+ {
+ return $this->client->query(method: 'POST', endpoint: 'v1/themes', options: [
+ 'json' => array_merge(['name' => $name], Util::omitNull([
+ 'styles' => $styles,
+ ]))
+ ]);
+ }
+
public function get(string $theme_id): mixed
{
return $this->client->query(method: 'GET', endpoint: 'v1/themes/' . $theme_id);
}
+
+ public function update(string $theme_id, ?string $name = null, ?array $styles = null): mixed
+ {
+ $payload = Util::omitNull([
+ 'name' => $name,
+ 'styles' => $styles,
+ ]);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one of name or styles must be provided.');
+ }
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/themes/' . $theme_id, options: [
+ 'json' => $payload
+ ]);
+ }
}
diff --git a/src/Workflows.php b/src/Workflows.php
index 6a9abf6..b26a97c 100644
--- a/src/Workflows.php
+++ b/src/Workflows.php
@@ -23,13 +23,180 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed
]);
}
+ public function create(
+ string $name,
+ ?string $description = null,
+ ?string $mailing_list_id = null
+ ): mixed {
+ return $this->client->query(method: 'POST', endpoint: 'v1/workflows', options: [
+ 'json' => array_merge(['name' => $name], Util::omitNull([
+ 'description' => $description,
+ 'mailingListId' => $mailing_list_id,
+ ]))
+ ]);
+ }
+
public function get(string $workflow_id): mixed
{
return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $workflow_id);
}
+ public function update(
+ string $workflow_id,
+ ?string $expected_revision_id,
+ ?string $name = null,
+ ?string $description = null
+ ): mixed {
+ $payload = Util::omitNull([
+ 'name' => $name,
+ 'description' => $description,
+ ]);
+ if ($payload === []) {
+ throw new \InvalidArgumentException(message: 'At least one of name or description must be provided.');
+ }
+ $payload = array_merge(['expectedRevisionId' => $expected_revision_id], $payload);
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/workflows/' . $workflow_id, options: [
+ 'json' => $payload
+ ]);
+ }
+
+ public function changeMailingList(
+ string $workflow_id,
+ ?string $expected_revision_id,
+ mixed $mailing_list_id,
+ ?bool $dry_run = null,
+ ?string $queued_contact_policy = null
+ ): mixed {
+ $payload = array_merge(
+ [
+ 'expectedRevisionId' => $expected_revision_id,
+ 'mailingListId' => $mailing_list_id,
+ ],
+ Util::omitNull([
+ 'dryRun' => $dry_run,
+ 'queuedContactPolicy' => $queued_contact_policy,
+ ])
+ );
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/workflows/' . $workflow_id . '/mailing-list', options: [
+ 'json' => $payload
+ ]);
+ }
+
public function getNode(string $workflow_id, string $node_id): mixed
{
return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id);
}
+
+ public function createNode(
+ string $workflow_id,
+ ?string $expected_revision_id,
+ string $insert_mode,
+ string $node_type_name,
+ ?string $from_node_id = null,
+ ?string $to_node_id = null,
+ ?string $before_node_id = null
+ ): mixed {
+ $payload = [
+ 'expectedRevisionId' => $expected_revision_id,
+ 'insertMode' => $insert_mode,
+ 'nodeTypeName' => $node_type_name,
+ ];
+
+ if ($insert_mode === 'between') {
+ $payload['fromNodeId'] = $from_node_id;
+ $payload['toNodeId'] = $to_node_id;
+ } elseif ($insert_mode === 'before') {
+ $payload['beforeNodeId'] = $before_node_id;
+ } else {
+ throw new \InvalidArgumentException(message: 'insert_mode must be "between" or "before".');
+ }
+
+ return $this->client->query(method: 'POST', endpoint: 'v1/workflows/' . $workflow_id . '/nodes', options: [
+ 'json' => $payload
+ ]);
+ }
+
+ public function updateNode(
+ string $workflow_id,
+ string $node_id,
+ ?string $expected_revision_id,
+ array $payload
+ ): mixed {
+ return $this->client->query(
+ method: 'POST',
+ endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id,
+ options: [
+ 'json' => [
+ 'expectedRevisionId' => $expected_revision_id,
+ 'payload' => $payload,
+ ]
+ ]
+ );
+ }
+
+ public function deleteNode(
+ string $workflow_id,
+ string $node_id,
+ ?string $expected_revision_id,
+ ?bool $dry_run = null,
+ ?string $queued_contact_policy = null
+ ): mixed {
+ $payload = array_merge(
+ ['expectedRevisionId' => $expected_revision_id],
+ Util::omitNull([
+ 'dryRun' => $dry_run,
+ 'queuedContactPolicy' => $queued_contact_policy,
+ ])
+ );
+
+ return $this->client->query(
+ method: 'DELETE',
+ endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id,
+ options: [
+ 'json' => $payload
+ ]
+ );
+ }
+
+ public function addBranch(
+ string $workflow_id,
+ string $node_id,
+ ?string $expected_revision_id
+ ): mixed {
+ return $this->client->query(
+ method: 'POST',
+ endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id . '/add-branch',
+ options: [
+ 'json' => [
+ 'expectedRevisionId' => $expected_revision_id,
+ ]
+ ]
+ );
+ }
+
+ public function deleteNodeRecursive(
+ string $workflow_id,
+ string $node_id,
+ ?string $expected_revision_id,
+ ?bool $dry_run = null,
+ ?string $queued_contact_policy = null
+ ): mixed {
+ $payload = array_merge(
+ ['expectedRevisionId' => $expected_revision_id],
+ Util::omitNull([
+ 'dryRun' => $dry_run,
+ 'queuedContactPolicy' => $queued_contact_policy,
+ ])
+ );
+
+ return $this->client->query(
+ method: 'DELETE',
+ endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id . '/recursive',
+ options: [
+ 'json' => $payload
+ ]
+ );
+ }
}
diff --git a/tests/AudienceSegmentsTest.php b/tests/AudienceSegmentsTest.php
index c417055..b17a3a5 100644
--- a/tests/AudienceSegmentsTest.php
+++ b/tests/AudienceSegmentsTest.php
@@ -43,6 +43,52 @@ public function testListAudienceSegments(): void
$this->assertEquals([], $result['data']);
}
+ public function testCreateAudienceSegment(): void
+ {
+ $filter = [
+ 'match' => 'all',
+ 'conditions' => [
+ [
+ 'type' => 'property',
+ 'key' => 'plan',
+ 'operator' => 'equals',
+ 'value' => 'pro',
+ ],
+ ],
+ ];
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/audience-segments',
+ $this->callback(function ($options) use ($filter) {
+ return $options['json'] === [
+ 'name' => 'Power users',
+ 'filter' => $filter,
+ 'description' => 'Contacts on the pro plan',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => 'seg_123',
+ 'name' => 'Power users',
+ 'description' => 'Contacts on the pro plan',
+ 'filter' => $filter,
+ ])
+ ));
+
+ $result = $this->client->audienceSegments->create(
+ name: 'Power users',
+ filter: $filter,
+ description: 'Contacts on the pro plan'
+ );
+
+ $this->assertEquals('seg_123', $result['id']);
+ }
+
public function testGetAudienceSegment(): void
{
$segmentId = 'seg_123';
diff --git a/tests/ComponentsTest.php b/tests/ComponentsTest.php
index b5f5ce0..ddeba94 100644
--- a/tests/ComponentsTest.php
+++ b/tests/ComponentsTest.php
@@ -62,4 +62,68 @@ public function testFindComponent(): void
$this->assertEquals($componentId, $result['componentId']);
}
+
+ public function testCreateComponent(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/components',
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'name' => 'Header',
+ 'lmx' => 'Welcome',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 201,
+ body: json_encode([
+ 'id' => 'component_abc123',
+ 'name' => 'Header',
+ 'lmx' => 'Welcome',
+ ])
+ ));
+
+ $result = $this->client->components->create(
+ name: 'Header',
+ lmx: 'Welcome'
+ );
+
+ $this->assertEquals('Header', $result['name']);
+ }
+
+ public function testUpdateComponent(): void
+ {
+ $componentId = 'component_abc123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/components/' . $componentId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'lmx' => 'Updated',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $componentId,
+ 'name' => 'Header',
+ 'lmx' => 'Updated',
+ 'affectedEmailCount' => 3,
+ ])
+ ));
+
+ $result = $this->client->components->update(
+ component_id: $componentId,
+ lmx: 'Updated'
+ );
+
+ $this->assertEquals(3, $result['affectedEmailCount']);
+ }
}
diff --git a/tests/EmailMessagesTest.php b/tests/EmailMessagesTest.php
index 2180711..2ea2566 100644
--- a/tests/EmailMessagesTest.php
+++ b/tests/EmailMessagesTest.php
@@ -124,4 +124,66 @@ public function testPreviewEmailMessage(): void
$this->assertTrue($result['success']);
}
+
+ public function testGuardianEmailMessage(): void
+ {
+ $emailMessageId = 'msg_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/email-messages/' . $emailMessageId . '/guardian')
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'errors' => [],
+ 'warnings' => []
+ ])
+ ));
+
+ $result = $this->client->emailMessages->guardian(email_message_id: $emailMessageId);
+
+ $this->assertEquals([], $result['errors']);
+ $this->assertEquals([], $result['warnings']);
+ }
+
+ public function testUpdateEmailMessageWithExtraFields(): void
+ {
+ $emailMessageId = 'msg_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/email-messages/' . $emailMessageId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'ccEmail' => 'cc@example.com',
+ 'bccEmail' => 'bcc@example.com',
+ 'languageCode' => 'en',
+ 'emailFormat' => 'plain',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $emailMessageId,
+ 'emailFormat' => 'plain',
+ 'contentRevisionId' => 'rev_456',
+ ])
+ ));
+
+ $result = $this->client->emailMessages->update(
+ email_message_id: $emailMessageId,
+ expected_revision_id: 'rev_123',
+ cc_email: 'cc@example.com',
+ bcc_email: 'bcc@example.com',
+ language_code: 'en',
+ email_format: 'plain'
+ );
+
+ $this->assertEquals('plain', $result['emailFormat']);
+ }
}
diff --git a/tests/EventPatternsTest.php b/tests/EventPatternsTest.php
new file mode 100644
index 0000000..74a4c71
--- /dev/null
+++ b/tests/EventPatternsTest.php
@@ -0,0 +1,83 @@
+mockHttpClient = $this->createMock(\GuzzleHttp\Client::class);
+ $this->client = new LoopsClient('test_api_key');
+ $this->client->setHttpClient($this->mockHttpClient);
+ }
+
+ public function testListEventPatterns(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/event-patterns')
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'pagination' => ['nextCursor' => null],
+ 'data' => []
+ ])
+ ));
+
+ $result = $this->client->eventPatterns->list();
+
+ $this->assertEquals([], $result['data']);
+ }
+
+ public function testGetEventPattern(): void
+ {
+ $eventPatternId = 'ep_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/event-patterns/' . $eventPatternId)
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $eventPatternId,
+ 'eventName' => 'signup',
+ 'eventProperties' => [],
+ 'incomingWebhookPlatform' => null,
+ ])
+ ));
+
+ $result = $this->client->eventPatterns->get(event_pattern_id: $eventPatternId);
+
+ $this->assertEquals('signup', $result['eventName']);
+ }
+
+ public function testGetEventPatternByName(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('get')
+ ->with('v1/event-patterns/by-name/' . rawurlencode('Payment Received'))
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => 'ep_456',
+ 'eventName' => 'Payment Received',
+ 'eventProperties' => [],
+ 'incomingWebhookPlatform' => null,
+ ])
+ ));
+
+ $result = $this->client->eventPatterns->getByName(event_name: 'Payment Received');
+
+ $this->assertEquals('ep_456', $result['id']);
+ }
+}
diff --git a/tests/ThemesTest.php b/tests/ThemesTest.php
index b4995b6..1670089 100644
--- a/tests/ThemesTest.php
+++ b/tests/ThemesTest.php
@@ -69,4 +69,65 @@ public function testFindTheme(): void
$this->assertEquals($themeId, $result['themeId']);
}
+
+ public function testCreateTheme(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/themes',
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'name' => 'Dark mode',
+ 'styles' => ['backgroundColor' => '#111827'],
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 201,
+ body: json_encode([
+ 'id' => 'theme_abc123',
+ 'name' => 'Dark mode',
+ 'styles' => ['backgroundColor' => '#111827'],
+ 'isDefault' => false,
+ ])
+ ));
+
+ $result = $this->client->themes->create(
+ name: 'Dark mode',
+ styles: ['backgroundColor' => '#111827']
+ );
+
+ $this->assertEquals('Dark mode', $result['name']);
+ }
+
+ public function testUpdateTheme(): void
+ {
+ $themeId = 'theme_abc123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/themes/' . $themeId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'name' => 'Updated theme',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $themeId,
+ 'name' => 'Updated theme',
+ 'affectedEmailCount' => 0,
+ ])
+ ));
+
+ $result = $this->client->themes->update(theme_id: $themeId, name: 'Updated theme');
+
+ $this->assertEquals('Updated theme', $result['name']);
+ }
}
diff --git a/tests/WorkflowsTest.php b/tests/WorkflowsTest.php
index 2269cd9..2a8f24a 100644
--- a/tests/WorkflowsTest.php
+++ b/tests/WorkflowsTest.php
@@ -37,6 +37,110 @@ public function testListWorkflows(): void
$this->assertEquals([], $result['data']);
}
+ public function testCreateWorkflow(): void
+ {
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/workflows',
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'name' => 'Welcome series',
+ 'description' => 'Onboarding emails',
+ 'mailingListId' => 'list_123',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => 'wf_123',
+ 'name' => 'Welcome series',
+ 'status' => 'Draft',
+ ])
+ ));
+
+ $result = $this->client->workflows->create(
+ name: 'Welcome series',
+ description: 'Onboarding emails',
+ mailing_list_id: 'list_123'
+ );
+
+ $this->assertEquals('wf_123', $result['id']);
+ }
+
+ public function testUpdateWorkflow(): void
+ {
+ $workflowId = 'wf_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/workflows/' . $workflowId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'name' => 'Updated name',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $workflowId,
+ 'name' => 'Updated name',
+ 'workflowRevisionId' => 'rev_456',
+ ])
+ ));
+
+ $result = $this->client->workflows->update(
+ workflow_id: $workflowId,
+ expected_revision_id: 'rev_123',
+ name: 'Updated name'
+ );
+
+ $this->assertEquals('Updated name', $result['name']);
+ }
+
+ public function testChangeMailingList(): void
+ {
+ $workflowId = 'wf_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/workflows/' . $workflowId . '/mailing-list',
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'mailingListId' => 'list_456',
+ 'dryRun' => true,
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'status' => 'dryRun',
+ 'mailingListId' => 'list_456',
+ 'queuedContactCount' => 0,
+ 'queuedContactLimitReached' => false,
+ ])
+ ));
+
+ $result = $this->client->workflows->changeMailingList(
+ workflow_id: $workflowId,
+ expected_revision_id: 'rev_123',
+ mailing_list_id: 'list_456',
+ dry_run: true
+ );
+
+ $this->assertEquals('dryRun', $result['status']);
+ }
+
public function testGetWorkflowNode(): void
{
$workflowId = 'wf_123';
@@ -50,7 +154,7 @@ public function testGetWorkflowNode(): void
status: 200,
body: json_encode([
'id' => $nodeId,
- 'type' => 'email'
+ 'typeName' => 'SendEmailAction'
])
));
@@ -58,4 +162,192 @@ public function testGetWorkflowNode(): void
$this->assertEquals($nodeId, $result['id']);
}
+
+ public function testCreateNodeBetween(): void
+ {
+ $workflowId = 'wf_123';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/workflows/' . $workflowId . '/nodes',
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'insertMode' => 'between',
+ 'nodeTypeName' => 'TimerAction',
+ 'fromNodeId' => 'node_1',
+ 'toNodeId' => 'node_2',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'node' => ['id' => 'node_3', 'typeName' => 'TimerAction'],
+ 'workflow' => ['id' => $workflowId],
+ ])
+ ));
+
+ $result = $this->client->workflows->createNode(
+ workflow_id: $workflowId,
+ expected_revision_id: 'rev_123',
+ insert_mode: 'between',
+ node_type_name: 'TimerAction',
+ from_node_id: 'node_1',
+ to_node_id: 'node_2'
+ );
+
+ $this->assertEquals('node_3', $result['node']['id']);
+ }
+
+ public function testUpdateNode(): void
+ {
+ $workflowId = 'wf_123';
+ $nodeId = 'node_456';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/workflows/' . $workflowId . '/nodes/' . $nodeId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'payload' => [
+ 'amount' => 2,
+ 'unit' => 'd',
+ ],
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'id' => $nodeId,
+ 'typeName' => 'TimerAction',
+ 'amount' => 2,
+ 'unit' => 'd',
+ 'workflowRevisionId' => 'rev_456',
+ ])
+ ));
+
+ $result = $this->client->workflows->updateNode(
+ workflow_id: $workflowId,
+ node_id: $nodeId,
+ expected_revision_id: 'rev_123',
+ payload: ['amount' => 2, 'unit' => 'd']
+ );
+
+ $this->assertEquals(2, $result['amount']);
+ }
+
+ public function testDeleteNode(): void
+ {
+ $workflowId = 'wf_123';
+ $nodeId = 'node_456';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('delete')
+ ->with(
+ 'v1/workflows/' . $workflowId . '/nodes/' . $nodeId,
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'queuedContactPolicy' => 'discard',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'status' => 'deleted',
+ 'nodeIds' => [$nodeId],
+ 'workflowRevisionId' => 'rev_456',
+ 'queuedContactCount' => 0,
+ 'queuedContactLimitReached' => false,
+ ])
+ ));
+
+ $result = $this->client->workflows->deleteNode(
+ workflow_id: $workflowId,
+ node_id: $nodeId,
+ expected_revision_id: 'rev_123',
+ queued_contact_policy: 'discard'
+ );
+
+ $this->assertEquals('deleted', $result['status']);
+ }
+
+ public function testAddBranch(): void
+ {
+ $workflowId = 'wf_123';
+ $nodeId = 'node_branch';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('post')
+ ->with(
+ 'v1/workflows/' . $workflowId . '/nodes/' . $nodeId . '/add-branch',
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'node' => ['id' => 'node_child', 'typeName' => 'AudienceFilter'],
+ 'workflow' => ['id' => $workflowId],
+ ])
+ ));
+
+ $result = $this->client->workflows->addBranch(
+ workflow_id: $workflowId,
+ node_id: $nodeId,
+ expected_revision_id: 'rev_123'
+ );
+
+ $this->assertEquals('node_child', $result['node']['id']);
+ }
+
+ public function testDeleteNodeRecursive(): void
+ {
+ $workflowId = 'wf_123';
+ $nodeId = 'node_456';
+
+ $this->mockHttpClient
+ ->expects($this->once())
+ ->method('delete')
+ ->with(
+ 'v1/workflows/' . $workflowId . '/nodes/' . $nodeId . '/recursive',
+ $this->callback(function ($options) {
+ return $options['json'] === [
+ 'expectedRevisionId' => 'rev_123',
+ 'dryRun' => true,
+ ];
+ })
+ )
+ ->willReturn(new Response(
+ status: 200,
+ body: json_encode([
+ 'status' => 'dryRun',
+ 'nodeIds' => [$nodeId, 'node_789'],
+ 'queuedContactCount' => 0,
+ 'queuedContactLimitReached' => false,
+ ])
+ ));
+
+ $result = $this->client->workflows->deleteNodeRecursive(
+ workflow_id: $workflowId,
+ node_id: $nodeId,
+ expected_revision_id: 'rev_123',
+ dry_run: true
+ );
+
+ $this->assertEquals('dryRun', $result['status']);
+ }
}