motordesk/api2 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

motordesk/api2

Composer 安装命令:

composer require motordesk/api2

包简介

Official PHP SDK for the MotorDesk API — automotive dealer management & retail. Full endpoint coverage, zero dependencies. Build integrations, automations, and AI agent tools.

README 文档

README

The official PHP client for the MotorDesk API v2, the automotive dealer-management and retail platform. It covers every endpoint (185 operations) through typed resource classes and handles auth and the response envelope for you - a clean, dependency-free integration layer for dealership software, automations, and AI agents. Ships with a runnable example that takes a vehicle from draft to completed sale.

  • Zero runtime dependencies - uses only ext-curl and ext-json. Install with Composer, or drop it into any project with the bundled autoloader.
  • Complete coverage - one method per endpoint across 16 resource groups (vehicles(), contacts(), leads(), invoices(), webhooks(), …). See the full endpoint reference.
  • Predictable - every call returns the unwrapped data, or throws a typed ApiException carrying the status, error code and per-field details.
  • Transparent - the exact request, response, HTTP status, and any recovered server noise are inspectable on the client after each call.

Contents

Requirements

  • PHP 8.1+ with the curl and json extensions (both standard).
  • A MotorDesk scoped API key in the form key_id.secret.

Installation

composer require motordesk/api2

The package has no runtime dependencies (only ext-curl / ext-json). If you don't use Composer, require the bundled PSR-4 autoloader instead:

require '/path/to/motordesk-api2/src/autoload.php';

use MotorDesk\Client;

Quick start

require 'src/autoload.php';

use MotorDesk\Client;
use MotorDesk\ApiException;

$client = new Client(apiKey: 'key_id.secret');

try {
    // List the 20 most recent vehicles
    $vehicles = $client->vehicles()->list(['per_page' => 20]);

    // Create a draft vehicle
    $vehicle = $client->vehicles()->create([
        'registration' => 'AB12 CDE',
        'data' => ['vehicle' => ['type' => 'Car', 'make' => 'Ford', 'model' => 'Fiesta']],
    ]);
    echo "Created vehicle #{$vehicle['id']}\n";
} catch (ApiException $e) {
    fwrite(STDERR, "{$e->getMessage()} ({$e->errorCode})\n");
    foreach ($e->detailLines() as $line) {
        fwrite(STDERR, "  - {$line}\n");
    }
}

Configuration

The client is configured entirely through its constructor:

$client = new Client(
    apiKey:     'key_id.secret',                       // required
    baseUrl:    'https://api.motordesk.com/2.0',       // default (production)
    verifySsl:  true,                                  // verify TLS - keep true in production
    timeout:    30,                                    // per-request timeout (seconds)
    maxRetries: 2,                                     // retry transient failures (see below)
);

Transient failures are retried automatically with exponential backoff: network errors and 429/5xx on idempotent methods, plus 429 on any method (a rate limit means the request wasn't processed). POST/PATCH are never retried on a 5xx - unless you pass an idempotency key (see Idempotency), which makes the write safe to repeat. Set maxRetries: 0 to disable.

  • Production: https://api.motordesk.com/2.0

The example app reads these from config.php (copy config.example.php) or the MOTORDESK_API_KEY / MOTORDESK_BASE_URL / MOTORDESK_VERIFY_SSL environment variables - but the SDK itself has no opinion on where you keep credentials.

Keep verifySsl: true for production. Set it to false only for a local dev server with a self-signed certificate.

Authentication & scopes

Pass a scoped key as apiKey; it is sent as Authorization: Bearer key_id.secret. Keys are scoped per area (e.g. vehicles:write, contacts:write, invoices:write, vehicle-pricing:read). If a call needs a scope your key lacks, the API returns a 403/422 naming the missing scope in error.details - surfaced on ApiException (see Error handling).

Making requests

Resources & accessors

Endpoints are grouped into resource classes, each reached through an accessor on the client. Methods map one-to-one to endpoints:

$client->vehicles()->get($id);                       // GET   /vehicles/{id}
$client->vehicles()->update($id, ['data' => [...]]); // PATCH /vehicles/{id}
$client->vehicles()->listMedia($id);                 // GET   /vehicles/{id}/media
$client->vehicles()->addStageNote($id, $jobId, $stage, ['note' => 'Done']);
$client->contacts()->create(['name' => 'Jane Smith', 'email' => 'jane@example.com']);
$client->leads()->addNote($leadId, ['note' => 'Called back']);
$client->invoices()->recordPayment($id, ['amount' => 9495.00, 'method' => 'Bank Transfer']);
$client->reference()->listSites();                   // GET   /reference/sites

// Vehicle lookup - `type` is the country code (UK; GB→UK) or "VIN", not "registration":
$client->vehicles()->lookup(['type' => 'UK', 'identifier' => 'MT04DSK']);

Accessors (all cached): vehicles(), contacts(), leads(), invoices(), orders(), purchases(), appointments(), documents(), blogs(), reviews(), calls(), deals(), listings(), reference(), webhooks(), meta().

Method naming

  • CRUD: list(), create(), get($id), update($id, $body), delete($id).
  • Sub-resources and actions read naturally: listMedia(), addMedia(), publishChannels(), reorderJobStages(), recordPayment(), checkDuplicate(), completeSale(). Each method's PHPDoc names the underlying VERB /path.

Arguments

  • Path parameters are positional (int|string), in path order: addStageNote($id, $jobId, $stage, $body).
  • Write methods (POST/PUT/PATCH) take an array $body, followed by an optional ?string $idempotencyKey (see Idempotency).
  • GET methods take an optional array $query for page / per_page, sparse fields, and filters.

Generic verbs

For anything not yet wrapped (or to call a raw path), the verb methods are public:

$client->get('/vehicles', ['per_page' => 5]);
$client->post('/vehicles/123/notes', ['note' => '']);
$client->put('/vehicles/123/channels', ['channels' => ['website']]);
$client->patch('/vehicles/123', ['data' => [...]]);
$client->delete('/vehicles/123/media/456');
$client->head('/vehicles/123');   // headers + status only, no body

Response headers & rate limits

Every call captures the response headers as a lowercased name => value map on $client->lastHeaders, with lastHeader() for a single case-insensitive lookup. rateLimit() reads the daily X-RateLimit-*-Day headers as ints (each null when the header is absent):

$client->vehicles()->list();

$client->lastHeader('X-RateLimit-Remaining-Day');   // e.g. "4987"
['limit' => $limit, 'remaining' => $remaining, 'reset' => $reset] = $client->rateLimit();

head() issues a body-less HEAD request - a cheap existence/freshness probe, or a way to read rate-limit state without fetching a payload. It returns the header map and sets $client->lastStatus.

Idempotency

Write methods (POST/PUT/PATCH) and the generic post()/put()/patch() verbs take an optional idempotency key as the last argument. It is sent as the Idempotency-Key header; the API de-duplicates requests that reuse the same key, so a retried call won't create a second record.

$key = bin2hex(random_bytes(16));                       // stable per logical operation
$client->invoices()->create($body, $key);               // resource method
$client->post('/invoices', $body, $key);                // generic verb

Passing a key also makes the write safe to auto-retry: with a key set, a POST/PATCH that hits a network error or transient 5xx is retried like an idempotent method (subject to maxRetries). Reuse the same key when you retry a logical operation yourself, and use a fresh key for each new one.

Pagination & sparse fieldsets

List endpoints paginate with page / per_page; the pagination block is exposed on the client after each call as lastMeta:

$page = $client->vehicles()->list(['page' => 2, 'per_page' => 50]);
$pagination = $client->lastMeta['pagination'] ?? null;
// ['page' => 2, 'per_page' => 50, 'total' => 240, 'total_pages' => 5, 'next_cursor' => '…']

Or let the client walk every page for you with paginate(), which yields each item:

foreach ($client->paginate('/vehicles', ['status' => 'for-sale']) as $vehicle) {
    // …
}

Use sparse fieldsets to fetch only the fields you need (dot-pathed):

$client->vehicles()->list(['fields' => 'id,status,data.stock.price_website']);

Error handling

Any non-success envelope or transport failure throws MotorDesk\ApiException:

use MotorDesk\ApiException;

try {
    $client->vehicles()->create(['data' => ['vehicle' => ['type' => 'car']]]);
} catch (ApiException $e) {
    $e->getMessage();   // "The data.vehicle.type field is not a recognised vehicle class…"
    $e->httpStatus;     // 422
    $e->errorCode;      // "validation_failed"
    $e->details;        // ['field' => 'data.vehicle.type', 'allowed_values' => ['Car', 'Van', …]]
    $e->detailLines();  // ['field: data.vehicle.type', 'allowed_values: Car; Van; …']
    $e->body;           // full decoded response body (for debugging)
}

Inspecting requests & responses

After every call the client exposes what just happened - handy for logging, debugging, or teaching:

$client->lastRequest;   // ['method' => 'POST', 'url' => '…', 'body' => [...]]
$client->lastResponse;  // full decoded envelope: ['success' => true, 'data' => …, 'meta' => …]
$client->lastStatus;    // 201
$client->lastHeaders;   // ['content-type' => 'application/json; charset=utf-8', …]
$client->lastWarning;   // usually null

Leaked-output recovery. If a response arrives with non-JSON noise around the envelope (e.g. a stray PHP notice prepended to the body by the server), the client extracts the JSON envelope so the call still succeeds, and records the stripped text on $client->lastWarning instead of failing - so the problem stays visible rather than breaking your integration. On clean responses lastWarning is null.

Workflow shortcuts

For the common "list a car and sell it" journey, the client also offers flat convenience methods (thin wrappers over the resources above), kept for readability and backwards compatibility:

$client->createVehicle($data);                 // ≡ vehicles()->create()
$client->addVehicleMedia($id, $media);         // ≡ vehicles()->addMedia()
$client->publishVehicle($id, ['website']);     // ≡ vehicles()->publishChannels()
$client->createContact($data);                 // ≡ contacts()->create()
$client->reserveVehicle($id, $body);           // ≡ vehicles()->reserve()
$client->createInvoice($data);                 // ≡ invoices()->create()
$client->issueInvoice($id);                    // ≡ invoices()->issue()
$client->sendInvoice($id, $email);             // ≡ invoices()->email()
$client->payInvoice($id, $payment);            // ≡ invoices()->recordPayment()
$client->completeSale($id, $body);             // ≡ vehicles()->completeSale()

Endpoint reference

The complete list of resources and methods - 185 endpoints with their VERB /path and summary - is in docs/ENDPOINTS.md.

Example integration

A runnable demo lives in public/: a nine-step guided walkthrough that takes a vehicle from draft to a completed sale, where each step is a standalone form performing a single API call and showing the exact request and response.

# Step SDK call
1 Add a vehicle (with registration lookup to prefill) vehicles()->lookup(), createVehicle()
2 Upload images addVehicleMedia()
3 Publish for sale publishVehicle()
4 Create a contact createContact()
5 Reserve the vehicle reserveVehicle()
6 Create an invoice createInvoice()
7 Issue & email the invoice issueInvoice(), sendInvoice()
8 Mark paid → sold payInvoice()
9 Mark the sale complete completeSale()

Each step's form is the focus; the API request/response is shown in a collapsible panel below it. The sidebar's ⟳ Sync from MotorDesk button re-fetches the vehicle, contact and invoice and refreshes the workflow state from their current API status - so a change made directly in MotorDesk (e.g. reserving the vehicle, or taking a deposit) is picked up and the process can be continued from the SDK tool (a reservation even adopts its customer as the workflow contact).

Run it:

cp config.example.php config.php      # add your key + set base_url for your environment
php -S localhost:8000 -t public       # open http://localhost:8000

Prefer the command line? php examples/walkthrough.php runs the whole sequence end-to-end without a browser.

Separate example: Vehicle Jobs

public/jobs.php is a standalone "workshop board" example for the vehicle-jobs sub-API: apply a job board to a vehicle, then manage the resulting job's stages, tasks, notes, clocked time, documents and purchase invoices - covering every job endpoint: applyJobBoard(), listJobs(), getJob(), removeJob(), addJobStage() / updateJobStage() / removeJobStage() / reorderJobStages(), addStageTask() / updateStageTask() / removeStageTask(), addStageNote() / removeStageNote(), addStageClockingEntry() / removeStageClockingEntry(), uploadStageDocument() / removeStageDocument(), and uploadStagePurchaseInvoice() / removeStagePurchase(). A stage is marked done with a binary complete flag. Open it at /jobs.php (linked from the dashboard).

A job is created by applying a job board - a template configured per business in MotorDesk (Settings → Job Boards). If your business has none, the example shows a notice; configure one to use it.

Separate example: Webhooks

public/webhooks.php is a standalone example for the webhooks sub-API: subscribe a target URL to events (the literal * or a list picked from the event catalogue), then manage the subscription and its delivery log - covering every webhook endpoint: list(), create(), get(), update(), delete(), ping(), rotateSecret(), listDeliveries() and redeliver(). The event catalogue is read from reference()->listWebhookEvents(). Open it at /webhooks.php (linked from the dashboard).

The signing secret is returned only once - in the create response and again when rotated - so the example surfaces it in a one-time banner. Store it then and use it to verify the X-MotorDesk-Signature header on each delivery; afterwards only the masked secret_hint is visible.

Testing

Unit tests run without credentials (a fake transport stands in for the network):

composer install
composer test        # phpunit
composer analyse     # phpstan
composer cs          # php-cs-fixer (dry-run)

There's also a live integration harness:

php tests/full-workflow.php

It runs the whole workflow against the live API, verifies every documented state transition (draft → for-sale → reserved → sold → complete), runs targeted probes, and prints a summary of any issues found. It creates real records and sends a real email (to an @example.com test address), so point it at a non-production (dev) server only.

Project layout

config.example.php          → copy to config.php (example app only)
src/
  autoload.php              → PSR-4 autoloader (no Composer required)
  MotorDesk/
    Client.php              → transport, verbs, resource accessors, workflow shortcuts
    ApiException.php        → typed error (status, code, message, details)
    Resource/               → one class per API area, one method per endpoint
      AbstractResource.php
      Vehicles.php  Contacts.php  Leads.php  Invoices.php  Orders.php
      Purchases.php Appointments.php Documents.php Blogs.php Reviews.php
      Calls.php  Deals.php  Reference.php  Webhooks.php  Meta.php
    Http/                   → Transport interface + default CurlTransport
public/                     → the example integration (forms)
examples/walkthrough.php    → scripted end-to-end run
tests/                      → unit suite (Unit/) + live integration harness
bin/generate-resources.php  → regenerate Resource/* from the OpenAPI spec
docs/ENDPOINTS.md           → full endpoint reference

Gotchas

A few non-obvious API behaviours worth knowing:

  • Vehicle class is canonical and case-sensitive. data.vehicle.type must be one of Bike, Car, Caravan, Crossover, Farm, Motorhome, Plant, Truck, Van - car is rejected.
  • Publishing requires extra fields. A vehicle can't be published until data.history.condition, data.stock.vat and data.vehicle.drivetrain are set.
  • Vehicle lookup type is a country code or VIN. vehicles()->lookup() takes type = a 2-letter ISO country code (UK; GBUK) or VIN, not the word "registration".
  • Prices. data.stock.price_* are fixed 2-dp decimal strings ("9495.00"); invoice line prices are plain numbers. The display price is price_website.
  • Media read model. A vehicle's media lives under a media object (media.photo/video/youtube - item ids); vehicles()->listMedia() returns the detailed item list.
  • Job stages use a binary complete flag (no in-progress); tasks use status 0/10.

统计信息

  • 总下载量: 1
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 2
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-22

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固