Every Flutter team that ships to iOS has spent an afternoon it will never get back on pod install. A Ruby version bump breaks the CI image. A transitive pod pins a deployment target you don't want. Podfile.lock conflicts on every branch merge. And since CocoaPods went into maintenance mode, that pain has an expiry date rather than a fix.
Flutter's answer is Swift Package Manager. SPM support has been available behind a flag for several releases, it is on by default for newly created projects on recent stable channels, and CocoaPods remains supported for existing apps — for now. That combination is exactly why migration is a project rather than a one-line change: you will run both dependency managers side by side for a while.
This tutorial migrates a real app. We assume Flutter 3.4x stable, Xcode 16 or newer, and an app with a dozen or so plugins, some of them yours.
Why bother
The upside is concrete, not ideological:
- No Ruby toolchain. CocoaPods needs Ruby, gems, and often a
Gemfileto pin them. On CI that is an install step, a cache, and a class of "works on my machine" failures. SPM ships with Xcode. - Fewer generated files to fight.
Podfile,Podfile.lock, and thePods/project disappear once the migration is complete. Merge conflicts go with them. - Native ecosystem access. Thousands of iOS/macOS libraries publish SPM packages and nothing else. Today wrapping one in a plugin means authoring a podspec around it; with SPM you add a package dependency.
- Better Xcode integration. Package resolution, version conflicts, and source navigation happen inside Xcode instead of in a Ruby script that rewrites your workspace.
The downside is equally concrete: plugin coverage. A plugin only builds through SPM if its author has added a Package.swift, or if it is pure Dart with no native code. Everything else still needs CocoaPods. Which is fine — Flutter runs both at once.
Step 0: baseline the build you already have
Before changing anything, make the current state reproducible so you can prove what broke.
flutter --version
flutter doctor -v
# record the plugin set and versions you are migrating
flutter pub deps --style=compact > /tmp/deps-before.txt
# a clean release build of the current setup
flutter clean
flutter build ipa --release --no-codesign 2>&1 | tee /tmp/build-before.log
Commit /tmp/deps-before.txt somewhere reviewable, note the archive size from the log, and confirm the app launches on a physical device. Migrating build systems changes binary size and startup behaviour; you want a number to compare against.
Do this on a branch. Everything below touches generated iOS project files.
Step 1: turn on Swift Package Manager
SPM integration is a Flutter config flag, set per machine (and per CI runner):
flutter config --enable-swift-package-manager
flutter config --list | grep -i swift
Then let the tool rewrite the Xcode project wiring:
cd ios
rm -rf Pods Podfile.lock
cd ..
flutter clean
flutter pub get
flutter build ios --debug --no-codesign
On the first build after enabling the flag, Flutter does three things:
- Generates a local Swift package at
ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage. - Adds that package as a dependency of the
Runnertarget, plus a build phase that keeps it in sync withpubspec.yaml. - Leaves your
Podfilealone, so any plugin without SPM support keeps flowing through CocoaPods.
Open ios/Runner.xcworkspace and confirm you can see FlutterGeneratedPluginSwiftPackage under Package Dependencies. That package is generated — never edit it, never commit it. Add it to .gitignore if your repo doesn't already ignore ios/Flutter/ephemeral/.
If
flutter buildfails immediately with a message about the Xcode project being unmigrated, runflutter build iosonce more afterflutter clean. The migration step editsproject.pbxproj; some setups need the second pass. Review that diff carefully in code review — it is the one permanent change to your repo in this step.
Step 2: find out which plugins are actually SPM-ready
This is the part teams skip and then debug for two days. For each plugin with native iOS code, check whether it ships a Package.swift:
# after `flutter pub get`, inspect the resolved plugin sources
for d in ~/.pub-cache/hosted/pub.dev/*/ios; do
pkg=$(basename $(dirname "$d"))
if [ -f "$d/$(basename "$pkg" | cut -d- -f1)/Package.swift" ] || ls "$d"/*/Package.swift >/dev/null 2>&1; then
echo "SPM $pkg"
elif ls "$d"/*.podspec >/dev/null 2>&1; then
echo "POD $pkg"
fi
done | sort
A cleaner signal comes from the build itself. With SPM enabled, run:
flutter build ios --debug --no-codesign -v 2>&1 | grep -Ei "podfile|pod install|swift package"
If you see pod install still running, at least one plugin is CocoaPods-only. That is expected and supported: Flutter generates both the Swift package and the pod integration, and each plugin is routed through whichever it supports. Your job is only to know which is which, because the two behave differently when things go wrong.
Make a table in your migration ticket:
| Plugin | Native iOS | SPM ready | Action |
|---|---|---|---|
shared_preferences | yes | yes | none |
path_provider | yes | yes | none |
some_vendor_sdk | yes | no | keep pods, open upstream issue |
our_internal_analytics | yes | no | we own it — migrate it |
intl | no | n/a | none |
The last two rows are the actual work.
Step 3: migrate a plugin you own
Adding SPM support to a plugin is additive. Keep the podspec so apps that haven't migrated still build.
Starting layout:
our_internal_analytics/
ios/
Classes/
OurAnalyticsPlugin.swift
Assets/
our_internal_analytics.podspec
Target layout:
our_internal_analytics/
ios/
our_internal_analytics/ <- Swift package root
Package.swift
Sources/our_internal_analytics/
OurAnalyticsPlugin.swift
Resources/ <- moved assets
our_internal_analytics.podspec <- kept, repointed
The package manifest:
// ios/our_internal_analytics/Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "our_internal_analytics",
platforms: [
.iOS("13.0")
],
products: [
.library(name: "our-internal-analytics", targets: ["our_internal_analytics"])
],
dependencies: [
// native SPM dependencies go here — this is the payoff
.package(url: "https://github.com/vendor/analytics-ios.git", from: "5.2.0")
],
targets: [
.target(
name: "our_internal_analytics",
dependencies: [
.product(name: "VendorAnalytics", package: "analytics-ios")
],
resources: [
.process("Resources")
]
)
]
)
Three rules that will save you a build cycle each:
- Product name must not use underscores. Use
our-internal-analyticsfor the product,our_internal_analyticsfor the target. Flutter's generated package links products by that hyphenated convention. - Directory names matter. The package root must be
ios/<plugin_name>/and sources underSources/<target_name>/. - Declare the platform floor in
platforms:, and keep it at or below your app's deployment target. A plugin declaring.iOS("14.0")silently forces every consuming app to 14.
Update the podspec so the CocoaPods path compiles the same relocated sources:
# our_internal_analytics.podspec
s.source_files = 'our_internal_analytics/Sources/our_internal_analytics/**/*.swift'
s.resource_bundles = {
'our_internal_analytics_privacy' => ['our_internal_analytics/Sources/our_internal_analytics/Resources/**/*']
}
s.ios.deployment_target = '13.0'
If the plugin supports macOS too, repeat under macos/ — the layout and rules are identical, and the two manifests can be near-copies with a different platforms: line.
Then test both paths. This is non-negotiable for a plugin other teams consume:
cd example
# SPM path
flutter config --enable-swift-package-manager
flutter clean && flutter build ios --debug --no-codesign
# CocoaPods path
flutter config --no-enable-swift-package-manager
flutter clean && flutter build ios --debug --no-codesign
Step 4: resources and privacy manifests
SPM handles resources differently from CocoaPods, and this is where a migration most often changes runtime behaviour rather than build success.
Under CocoaPods a plugin's assets typically land in a resource bundle named by resource_bundles. Under SPM, .process("Resources") produces a bundle accessed through the generated Bundle.module accessor:
// Works under SPM
let url = Bundle.module.url(forResource: "config", withExtension: "json")
// Works under both: resolve by bundle identifier with a fallback
let bundle = Bundle(for: OurAnalyticsPlugin.self)
.url(forResource: "our_internal_analytics_privacy", withExtension: "bundle")
.flatMap(Bundle.init(url:)) ?? Bundle(for: OurAnalyticsPlugin.self)
If your plugin ships a PrivacyInfo.xcprivacy — and if it touches required-reason APIs on iOS it must — verify after migration that it is still present in the built app:
flutter build ipa --release --no-codesign
find build/ios -name "PrivacyInfo.xcprivacy" | sed 's|.*/||' | sort -u
unzip -l build/ios/ipa/*.ipa | grep -i xcprivacy
A missing privacy manifest is an App Store Connect rejection email, not a compiler error. It is the single highest-value check in this whole migration.
Step 5: CI
CI is where SPM pays for itself, provided you set the flag and move the cache.
# .github/workflows/ios.yml (excerpt)
jobs:
ios:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.44.0'
cache: true
- name: Enable Swift Package Manager
run: flutter config --enable-swift-package-manager
- name: Cache Swift packages
uses: actions/cache@v4
with:
path: ~/Library/Caches/org.swift.swiftpm
key: spm-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}
restore-keys: spm-${{ runner.os }}-
- run: flutter pub get
- run: flutter build ipa --release --no-codesign
Notes from doing this on real pipelines:
- The flag is per machine, so it must run on every ephemeral runner before the first build. Forgetting it produces a green build that silently used CocoaPods — and a different binary than your developers tested.
- Keep the CocoaPods cache step until the last pod-only plugin is gone. Deleting it early just slows the build; deleting it late costs nothing.
- Package resolution needs network access to any Git-hosted SPM dependency. If your runners are behind an allowlist, add those hosts before the migration lands, not during the incident.
- Pin
flutter-versionexplicitly. SPM behaviour has moved between minor releases more than most Flutter subsystems.
Step 6: verify, then decide about the Podfile
Compare against the baseline from Step 0:
flutter build ipa --release --no-codesign 2>&1 | tee /tmp/build-after.log
ls -l build/ios/ipa/*.ipa # compare size to before
Run through a checklist on a physical device, not just the simulator: cold start, anything using camera/location/notifications (those plugins have the most native surface), any feature reading plugin-bundled assets, and a release-mode build rather than debug. Simulator builds hide arm64 slice and code-signing problems.
Once flutter build ios -v no longer mentions pod install and every plugin in your table is SPM-ready, you can remove CocoaPods:
cd ios
rm -rf Pods Podfile Podfile.lock .symlinks
Do that as a separate, clearly labelled commit. It is the one step that is annoying to reverse.
Rollback
The escape hatch is one command plus a clean:
flutter config --no-enable-swift-package-manager
flutter clean
cd ios && pod install && cd ..
flutter build ios --debug --no-codesign
That is precisely why you keep the Podfile committed until the end. Teams that delete it in the same PR that enables SPM lose the ability to ship while they debug — and this migration always has one plugin that surprises you.
Failure modes we have actually hit
"Missing package product 'some-plugin'". The plugin's product name or directory layout doesn't match Flutter's expectations — usually an underscore in the product name, or sources not under Sources/<target>/. Fix the plugin, then flutter clean.
Deployment target creep. One SPM dependency declaring a higher minimum silently raises the floor for the app. Check platforms: in every package you add and keep IPHONEOS_DEPLOYMENT_TARGET in the Runner project as the source of truth.
Duplicate symbols. A native library pulled in twice — once as a pod by plugin A, once as an SPM dependency by plugin B. Pick one path for that library and align both plugins.
Stale generated package. Symptoms are a plugin that "isn't found" after a pubspec change. flutter clean regenerates FlutterGeneratedPluginSwiftPackage; deleting ios/Flutter/ephemeral/ by hand also works.
Xcode project diff noise. The migration edits project.pbxproj. Land it in its own commit so reviewers can read it, and make sure nobody's local Xcode reorders it again afterwards.
How to schedule the work
On client engagements we plan this as four short pieces of work rather than one big one:
- Baseline and audit — enable the flag locally, build in mixed mode, produce the plugin table. Half a day, no repo changes beyond the branch.
- Migrate first-party plugins — one PR per plugin, each tested through both dependency managers.
- Land mixed mode on
main— flag on in CI,Podfilestill committed, release one version this way. - Remove CocoaPods — only once the plugin table has no POD rows left.
The reason to start now, even if step 4 is a year away, is that steps 1 and 2 are the slow ones and they don't depend on upstream. CocoaPods being in maintenance mode means the deadline is set by other people's release schedules, not yours.
If you'd like help with the audit, or with adding SPM support to plugins your team maintains, get in touch — this is the kind of build-system work our Flutter consultants do alongside product delivery, without stopping the roadmap.