answear/acs-bundle
Symfony bundle for ACS pickup point integration. Configure API credentials via YAML, then use ParcelShopsService to fetch parcel shops by country (Greece or Cyprus) and optional kind, returning ParcelShop DTOs. Includes clear exceptions for service and response errors.
This is a Symfony bundle, not a Laravel package. There is no ServiceProvider, no auto-discovery in Laravel, and the README's config/bundles.php snippet does not apply. Before you install anything, decide which of two paths you're on:
Path A — Reuse the classes, wire the DI yourself (fastest to evaluate).
Add the repository (it is not on Packagist; the README's composer require [email protected]/... is a git URL, not a package name):
// composer.json
"repositories": [
{ "type": "vcs", "url": "https://github.com/answear/acs-bundle" }
]
composer require answear/acs-bundle
Then read AnswearAcsBundle\DependencyInjection\AnswearAcsExtension::load() first — that is the only place that tells you exactly which constructor arguments ParcelShopsService expects (typically a Guzzle client, a Symfony Serializer instance, and the config array). Mirror that in a Laravel provider:
// config/answear_acs.php
return [
'api_key' => env('ACS_API_KEY'),
'company_id' => env('ACS_COMPANY_ID'),
'company_password' => env('ACS_COMPANY_PASSWORD'),
'user_id' => env('ACS_USER_ID'),
'user_password' => env('ACS_USER_PASSWORD'),
'language' => env('ACS_LANGUAGE', 'GR'),
];
// app/Providers/AcsServiceProvider.php
public function register(): void
{
$this->app->singleton(ParcelShopsService::class, function ($app) {
$config = $app['config']->get('answear_acs');
// exact args per AnswearAcsExtension::load() — adjust after reading it
return new ParcelShopsService(
AcsClientFactory::make($config), // Guzzle client with timeout
new Serializer(), // symfony/serializer
$config,
);
});
}
Path B — Port it (recommended, ~500 LOC, half a day). The bundle is small. Copy Service/, Response/DTO/, Exception/, and the enum into app/Acs/ and replace Guzzle + Symfony Serializer with Laravel's Http facade and json_decode. You keep the DTO shapes, drop the Symfony weight, and own the timeout/retry story. This is what most Laravel teams end up doing.
First use case: a checkout "pick a parcel shop" dropdown. Expose GET /acs/parcel-shops?country=GR, return the DTO list as JSON, and persist the chosen shop ID on the order — the ID is what you'd later hand to whatever creates the actual ACS shipment (this bundle does not do shipping or tracking).
Wrap the vendor service behind your own interface. Do not let Answear\AcsBundle\* types leak into controllers, jobs, or Blade. One thin repository:
namespace App\Services\Acs;
use Answear\AcsBundle\Exception\MalformedResponse;
use Answear\AcsBundle\Exception\ServiceUnavailable;
use Answear\AcsBundle\Service\ParcelShopsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AcsParcelShopRepository
{
public function __construct(private ParcelShopsService $client) {}
/** @return \Answear\AcsBundle\Response\DTO\ParcelShop[] */
public function forCountry(string $iso2, ?int $kind = null): array
{
$country = \Answear\AcsBundle\Enum\CountryIdEnum::from($iso2); // verify enum path
return Cache::remember(
"acs.parcelshops.{$iso2}." . ($kind ?? 'all'),
now()->addHours(12),
function () use ($country, $kind) {
try {
return $this->client->getList($country, $kind);
} catch (ServiceUnavailable $e) {
Log::warning('ACS unavailable', ['prev' => $e->getPrevious()?->getMessage()]);
throw new AcsUnavailable($e->getMessage(), previous: $e);
} catch (MalformedResponse $e) {
Log::error('ACS schema drift', ['message' => $e->getMessage()]);
throw new AcsMalformed($e->getMessage(), previous: $e);
}
}
);
}
}
Cache aggressively. Parcel shop lists for GR/CY change on the order of weeks, not minutes. Cache for hours, warm it with a scheduled command, and always serve from cache on the checkout request path — never block a cart page on a live ACS call.
Map DTOs to an API resource. ACS property names and shapes are not your front-end contract. Do the translation in a JsonResource or a plain array_map, so upgrading the bundle (which has had BC breaks: 2.x → 3.x dropped PHP 7.4/8.0/8.1 and Symfony <6; 4.0.0 bumps to PHP 8.4) doesn't ripple into your JS.
return collect($shops)->map(fn ($s) => [
'id' => $s->id, // verify field names on ParcelShop
'name' => $s->name,
'address' => trim("{$s->street} {$s->streetNumber}, {$s->zip} {$s->city}"),
'kind' => $s->kind,
])->values();
Persist the choice, not the object. Store acs_parcel_shop_id on the order. When it's time to create the shipment, you call ACS's separate create-order API with that ID — the bundle does not cover that.
Testing: mock at your repository boundary, not inside Guzzle. Bind a fake in the container:
$this->mock(AcsParcelShopRepository::class, fn ($m) => $m
->shouldReceive('forCountry')->with('GR')->andReturn($this->fakeShops()));
If you go Path B (Laravel Http), Http::fake() gives you clean record/replay of ACS responses.
Version pinning matters more than usual. These are Laravel/PHP floor constraints, not preference:
| Bundle | PHP | Symfony | Works with |
|---|---|---|---|
| 4.0.0 | 8.4 | 7 | Laravel 11/12 on PHP 8.4 |
| 3.0.x | 8.2 | 6/7 | Laravel 10/11 |
| 2.1.x | 8.1 | 6.4 | Laravel 10 |
answear_acs.yaml, but the sample body starts with answear_gls:. Use your own key (answear_acs) in Laravel — you're writing the provider/port yourself anyway.apiKey, companyId, companyPassword, userId, userPassword. All five are required; a missing one surfaces as a 401 wrapped in ServiceUnavailable. Put them in .env, run php artisan config:cache, and rotate via your secret store, not the repo.ServiceUnavailable hides the real cause. It wraps every GuzzleException — DNS failure, timeout, 401, 403, 500 all look the same. Always log/inspect getPrevious(). A 401 during prod debugging is almost always a config typo, not an ACS outage.MalformedResponse means the schema moved. Log the raw body at debug level — without it you'll be guessing which field changed. Consider a Log::error + alert rather than a silent 500.Http::timeout(5)->connectTimeout(2) explicitly. On Path A, build the Guzzle client yourself with those options.Log::info (with duration) or let Telescope capture it; otherwise you're blind when ACS starts rate-limiting.CountryIdEnum is GR/CY only. Don't build multi-country logic around it — the enum will reject anything else. Gate the checkout option behind in_array($country, ['GR', 'CY']).kind = null means "all shop kinds." If you want lockers only, either pass the ACS kind code or filter on the DTO after fetch. Don't assume the API returns only staffed shops.AcsParcelShopRepository means replacing the bundle later is a one-file change.language default is GR. If your storefront is English, verify the DTO's address strings respect that; field-level i18n is not guaranteed.How can I help you explore Laravel packages today?