<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Abdullah</title>
    <description>The latest articles on DEV Community by Abdullah (@iabduul7).</description>
    <link>https://dev.to/iabduul7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F417329%2F2d580e78-4d91-4451-8451-ca45b811774c.jpeg</url>
      <title>DEV Community: Abdullah</title>
      <link>https://dev.to/iabduul7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iabduul7"/>
    <language>en</language>
    <item>
      <title>I Wrapped Disney, SeaWorld and Universal's Booking APIs in One Laravel Package</title>
      <dc:creator>Abdullah</dc:creator>
      <pubDate>Sun, 06 Sep 2026 13:33:31 +0000</pubDate>
      <link>https://dev.to/iabduul7/i-wrapped-disney-seaworld-and-universals-booking-apis-in-one-laravel-package-4f6a</link>
      <guid>https://dev.to/iabduul7/i-wrapped-disney-seaworld-and-universals-booking-apis-in-one-laravel-package-4f6a</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://github.com/iabduul7/laravel-themepark-booking-adapters" rel="noopener noreferrer"&gt;&lt;code&gt;iabduul7/laravel-themepark-booking-adapters&lt;/code&gt;&lt;/a&gt; gives you drop-in Laravel adapters for Disney and SeaWorld (via Redeam) and Universal Orlando (via SmartOrder). Auth, retries and OAuth tokens are handled. Below are the three design decisions I'd reuse in any API-wrapper package.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;If you sell theme park tickets online, you don't talk to Disney. You talk to a distributor called Redeam. SeaWorld is also on Redeam, but with a different supplier model. Universal runs its own OAuth2 API called SmartOrder.&lt;/p&gt;

&lt;p&gt;Three parks, two vendors, three ideas of what a "product" or a "ticket" is. For two years those integrations lived as private client classes inside a Laravel storefront I maintain. Every fix had to be made three times.&lt;/p&gt;

&lt;p&gt;So I extracted them into a package. It's a pure API layer: it talks to the provider and gives you typed DTOs back. Models, queues, margins and voucher PDFs stay in your app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Iabduul7\ThemeParkAdapters\Facades\ThemePark&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$disney&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ThemePark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'disney'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$disney&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAllProducts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                   &lt;span class="c1"&gt;// Product[]&lt;/span&gt;
&lt;span class="nv"&gt;$avail&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$disney&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;checkAvailabilities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PRODUCT_ID'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-06-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-06-30'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$hold&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$disney&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createNewHold&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$booking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$disney&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createNewBooking&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolution uses Laravel's own &lt;code&gt;Manager&lt;/code&gt; class, the same thing behind &lt;code&gt;Cache::store()&lt;/code&gt;, so there's nothing new to learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: Don't unify APIs that aren't unified
&lt;/h2&gt;

&lt;p&gt;My first attempt was one big &lt;code&gt;BookingProviderInterface&lt;/code&gt;. It fell apart fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disney's supplier ID is fixed in config.&lt;/li&gt;
&lt;li&gt;SeaWorld needs a supplier ID on every call.&lt;/li&gt;
&lt;li&gt;Universal has no concept of a hold at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Forcing those into one signature means arguments that two of three adapters silently ignore. Instead, the shared contract has two methods, and everything else is a &lt;strong&gt;capability interface&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;SupportsHolds&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* createNewHold, createNewBooking, deleteBooking … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;SupportsEvents&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* findEvents, placeOrder, cancelOrder … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ProvidesTicketArtifacts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?array&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redeam adapters implement &lt;code&gt;SupportsHolds&lt;/code&gt;. SmartOrder implements &lt;code&gt;SupportsEvents&lt;/code&gt;. All three implement &lt;code&gt;ProvidesTicketArtifacts&lt;/code&gt;. In your app you type-hint the capability, not the park:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;SupportsHolds&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more "this method exists but throws NotSupported".&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: Retry reads. Never retry writes.
&lt;/h2&gt;

&lt;p&gt;Retrying a &lt;code&gt;GET /products&lt;/code&gt; that timed out is free. Retrying a &lt;code&gt;POST /bookings&lt;/code&gt; that timed out can charge a customer twice for non-refundable tickets.&lt;/p&gt;

&lt;p&gt;The base adapter makes the rule impossible to miss by naming the helper after what it's for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * Use for idempotent reads ONLY — writes must never be
 * retried or a hold/booking/order could be duplicated.
 */&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;retryReads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PendingRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;PendingRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ConnectionException&lt;/span&gt;
            &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$exception&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;RequestException&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;serverError&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writes go straight through with no retry. Any non-2xx becomes a &lt;code&gt;ThemeParkApiException&lt;/code&gt; with the HTTP status in &lt;code&gt;getCode()&lt;/code&gt; and the provider's error body in &lt;code&gt;getResponseData()&lt;/code&gt;. A persistent 5xx is an exception, never a silent empty array.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: Check what &lt;code&gt;serialize()&lt;/code&gt; does before someone queues your object
&lt;/h2&gt;

&lt;p&gt;Every read returns a DTO like &lt;code&gt;Product&lt;/code&gt; or &lt;code&gt;Booking&lt;/code&gt;. Some keep a back-reference to the adapter so &lt;code&gt;$product-&amp;gt;getRates()&lt;/code&gt; can lazily hit the API.&lt;/p&gt;

&lt;p&gt;Convenient. Also dangerous. The adapter holds your API secrets, and the first thing a consuming app does with a &lt;code&gt;Product&lt;/code&gt; is dispatch it to a queue job. Laravel serializes the job, the job contains the DTO, the DTO contains the adapter, the adapter contains the credentials. Your API key is now sitting in Redis in plain text.&lt;/p&gt;

&lt;p&gt;The fix is five lines in the base DTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__serialize&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;   &lt;span class="c1"&gt;// adapter reference dropped&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;adapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a test that serializes a &lt;code&gt;Product&lt;/code&gt; and asserts the payload contains no secret. I hadn't thought about this until I saw a queue payload. Go check your own packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: OAuth that heals itself
&lt;/h2&gt;

&lt;p&gt;Universal's SmartOrder sometimes invalidates tokens early. The old client turned the resulting 401 into an empty catalog. The adapter now caches tokens under a key fingerprinted by credentials (so two accounts never share one), and on a 401 it refreshes exactly once and retries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;freshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;freshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;$freshToken&lt;/code&gt; field exists because version 4.0 minted a &lt;em&gt;third&lt;/em&gt; token on the retry when caching was off. The test for it is ten lines with &lt;code&gt;Http::sequence()&lt;/code&gt; and it's the whole bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's tested
&lt;/h2&gt;

&lt;p&gt;Around 60 contract tests with &lt;code&gt;Http::fake()&lt;/code&gt;, no real HTTP in the suite. CI runs PHP 8.2 to 8.4 against Laravel 12 and 13, plus PHPStan and Pint.&lt;/p&gt;

&lt;p&gt;Reads for all three parks are also verified against the live sandboxes. The full write lifecycle is proven live for Disney and Universal. &lt;strong&gt;SeaWorld writes are contract-tested only&lt;/strong&gt;, because its sandbox has no bookable inventory. I'd rather you read that here than find out in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require iabduul7/laravel-themepark-booking-adapters
php artisan vendor:publish &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"themepark-adapters-config"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop your Redeam or SmartOrder credentials in &lt;code&gt;.env&lt;/code&gt;, resolve a provider, and you're booking. PHP 8.2+ and Laravel 12 or 13.&lt;/p&gt;

&lt;p&gt;Source is on &lt;a href="https://github.com/iabduul7/laravel-themepark-booking-adapters" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you've built a wrapper around a messy third-party API, I'd like to hear which of these rules you'd argue with.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>opensource</category>
      <category>api</category>
    </item>
  </channel>
</rss>
